Commit 88f438cd authored by Lyza Danger Gardner's avatar Lyza Danger Gardner

Add preact annotation-publish-control component

parent b843736e
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const { applyTheme } = require('../util/theme');
const { withServices } = require('../util/service-context');
const Menu = require('./menu');
const MenuItem = require('./menu-item');
/**
* Render a compound control button for publishing (saving) an annotation:
* - Save the annotation — left side of button
* - Choose sharing/privacy option - drop-down menu on right side of button
*
*/
function AnnotationPublishControl({
group,
isDisabled,
isShared,
onCancel,
onSave,
onSetPrivacy,
settings,
}) {
const publishDestination = isShared ? group.name : 'Only Me';
const themeProps = ['ctaTextColor', 'ctaBackgroundColor'];
const menuLabel = (
<div className="annotation-publish-control__btn-dropdown-arrow">
<div className="annotation-publish-control__btn-dropdown-arrow-separator" />
<div
className="annotation-publish-control__btn-dropdown-arrow-indicator"
style={applyTheme(themeProps, settings)}
>
<div></div>
</div>
</div>
);
return (
<div className="annotation-publish-control">
<div className="annotation-publish-control__btn">
<button
className="annotation-publish-control__btn-primary"
style={applyTheme(themeProps, settings)}
onClick={onSave}
disabled={isDisabled}
title={`Publish this annotation to ${publishDestination}`}
>
Post to {publishDestination}
</button>
<Menu
arrowClass="annotation-publish-control__btn-menu-arrow"
containerPositioned={false}
contentClass="annotation-publish-control__btn-menu-content"
label={menuLabel}
menuIndicator={false}
title="Change annotation sharing setting"
align="left"
>
<MenuItem
icon={group.type === 'open' ? 'public' : 'groups'}
label={group.name}
isSelected={isShared}
onClick={() => onSetPrivacy({ level: 'shared' })}
/>
<MenuItem
icon="lock"
label="Only Me"
isSelected={!isShared}
onClick={() => onSetPrivacy({ level: 'private' })}
/>
</Menu>
</div>
<button
className="annotation-publish-control__cancel-btn btn-clean"
onClick={onCancel}
title="Cancel changes to this annotation"
>
<i className="h-icon-cancel-outline publish-annotation-cancel-btn__icon btn-icon" />{' '}
Cancel
</button>
</div>
);
}
AnnotationPublishControl.propTypes = {
/** The group the annotation is currently associated with */
group: propTypes.object.isRequired,
/**
* Should the save button be disabled?
* Hint: it will be if the annotation has no content
*/
isDisabled: propTypes.bool,
/** The current privacy setting on the annotation. Is it shared to group? */
isShared: propTypes.bool,
/** Callback for cancel button click */
onCancel: propTypes.func.isRequired,
/** Callback for save button click */
onSave: propTypes.func.isRequired,
/** Callback when selecting a privacy option in the menu */
onSetPrivacy: propTypes.func.isRequired,
/** services */
settings: propTypes.object.isRequired,
};
AnnotationPublishControl.injectedProps = ['settings'];
module.exports = withServices(AnnotationPublishControl);
'use strict';
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const AnnotationPublishControl = require('../annotation-publish-control');
const MenuItem = require('../menu-item');
describe('AnnotationPublishControl', () => {
let fakeGroup;
let fakeSettings;
let fakeApplyTheme;
const createAnnotationPublishControl = (props = {}) => {
return shallow(
<AnnotationPublishControl
group={fakeGroup}
isDisabled={false}
isShared={true}
onCancel={sinon.stub()}
onSave={sinon.stub()}
onSetPrivacy={sinon.stub()}
settings={fakeSettings}
{...props}
/>
).dive(); // Dive needed because this component uses `withServices`
};
beforeEach(() => {
fakeGroup = {
name: 'Fake Group',
type: 'private',
};
fakeSettings = {
branding: {
ctaTextColor: '#0f0',
ctaBackgroundColor: '#00f',
},
};
fakeApplyTheme = sinon.stub();
AnnotationPublishControl.$imports.$mock({
'../util/theme': {
applyTheme: fakeApplyTheme,
},
});
});
describe('theming', () => {
it('should apply theme styles', () => {
const fakeStyle = { foo: 'bar' };
fakeApplyTheme.returns(fakeStyle);
const wrapper = createAnnotationPublishControl();
const btnPrimary = wrapper.find(
'.annotation-publish-control__btn-primary'
);
assert.calledWith(
fakeApplyTheme,
['ctaTextColor', 'ctaBackgroundColor'],
fakeSettings
);
assert.include(btnPrimary.prop('style'), fakeStyle);
});
});
describe('dropdown menu button (form submit button)', () => {
const btnClass = '.annotation-publish-control__btn-primary';
context('shared annotation', () => {
it('should label the button with the group name', () => {
const wrapper = createAnnotationPublishControl({ isShared: true });
const btn = wrapper.find(btnClass);
assert.equal(
btn.prop('title'),
`Publish this annotation to ${fakeGroup.name}`
);
assert.equal(btn.text(), `Post to ${fakeGroup.name}`);
});
});
context('private annotation', () => {
it('should label the button with "Only Me"', () => {
const wrapper = createAnnotationPublishControl({ isShared: false });
const btn = wrapper.find(btnClass);
assert.equal(btn.prop('title'), 'Publish this annotation to Only Me');
assert.equal(btn.text(), 'Post to Only Me');
});
});
it('should disable the button if `isDisabled`', () => {
const wrapper = createAnnotationPublishControl({ isDisabled: true });
const btn = wrapper.find(btnClass);
assert.isOk(btn.prop('disabled'));
});
it('should enable the button if not `isDisabled`', () => {
const wrapper = createAnnotationPublishControl({ isDisabled: false });
const btn = wrapper.find(btnClass);
assert.isNotOk(btn.prop('disabled'));
});
it('should have a save callback', () => {
const fakeOnSave = sinon.stub();
const wrapper = createAnnotationPublishControl({ onSave: fakeOnSave });
const btn = wrapper.find(btnClass);
assert.equal(btn.prop('onClick'), fakeOnSave);
});
});
describe('menu', () => {
describe('share (to group) menu item', () => {
it('should invoke privacy callback with shared privacy', () => {
const fakeOnSetPrivacy = sinon.stub();
const wrapper = createAnnotationPublishControl({
onSetPrivacy: fakeOnSetPrivacy,
});
const shareMenuItem = wrapper.find(MenuItem).first();
shareMenuItem.prop('onClick')();
assert.calledWith(fakeOnSetPrivacy, { level: 'shared' });
});
it('should have a label that is the name of the group', () => {
const wrapper = createAnnotationPublishControl();
const shareMenuItem = wrapper.find(MenuItem).first();
assert.equal(shareMenuItem.prop('label'), fakeGroup.name);
});
context('private group', () => {
it('should have a group icon', () => {
const wrapper = createAnnotationPublishControl();
const shareMenuItem = wrapper.find(MenuItem).first();
assert.equal(shareMenuItem.prop('icon'), 'groups');
});
});
context('open group', () => {
beforeEach(() => {
fakeGroup.type = 'open';
});
it('should have a public icon', () => {
const wrapper = createAnnotationPublishControl();
const shareMenuItem = wrapper.find(MenuItem).first();
assert.equal(shareMenuItem.prop('icon'), 'public');
});
});
});
describe('private (only me) menu item', () => {
it('should invoke callback with private privacy', () => {
const fakeOnSetPrivacy = sinon.stub();
const wrapper = createAnnotationPublishControl({
onSetPrivacy: fakeOnSetPrivacy,
});
const privateMenuItem = wrapper.find(MenuItem).at(1);
privateMenuItem.prop('onClick')();
assert.calledWith(fakeOnSetPrivacy, { level: 'private' });
});
it('should use a private/lock icon', () => {
const wrapper = createAnnotationPublishControl();
const privateMenuItem = wrapper.find(MenuItem).at(1);
assert.equal(privateMenuItem.prop('icon'), 'lock');
});
it('should have an "Only me" label', () => {
const wrapper = createAnnotationPublishControl();
const privateMenuItem = wrapper.find(MenuItem).at(1);
assert.equal(privateMenuItem.prop('label'), 'Only Me');
});
});
});
describe('cancel button', () => {
it('should have a cancel callback', () => {
const fakeOnCancel = sinon.stub();
const wrapper = createAnnotationPublishControl({
onCancel: fakeOnCancel,
});
const cancelBtn = wrapper.find('.annotation-publish-control__cancel-btn');
cancelBtn.prop('onClick')();
assert.calledOnce(fakeOnCancel);
});
});
});
......@@ -147,6 +147,10 @@ function startAngularApp(config) {
'annotationActionButton',
wrapReactComponent(require('./components/annotation-action-button'))
)
.component(
'annotationPublishControl',
wrapReactComponent(require('./components/annotation-publish-control'))
)
.component(
'annotationShareDialog',
require('./components/annotation-share-dialog')
......
@import "../base";
@import '../base';
// See http://compass-style.org/reference/compass/utilities/general/clearfix/#mixin-pie-clearfix
@mixin pie-clearfix {
&:after {
content: "";
content: '';
display: table;
clear: both;
}
}
@mixin focus-outline {
border-color: #51A7E8;
box-shadow: 0px 1px 2px rgba(0, 0, 0, .075) inset, 0px 0px 5px rgba(81, 167, 232, .5);
border-color: #51a7e8;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.075) inset,
0px 0px 5px rgba(81, 167, 232, 0.5);
}
@mixin form-input {
@include font-normal;
border: 1px solid $gray-lighter;
border-radius: 2px;
padding: .5em .75em;
padding: 0.5em 0.75em;
font-weight: normal;
color: $gray;
background-color: #FAFAFA;
background-color: #fafafa;
}
@mixin form-input-focus {
outline: none;
background-color: #FFF;
background-color: #fff;
@include focus-outline;
@include placeholder {
......@@ -45,20 +46,20 @@
}
@mixin btn {
box-shadow: 0 1px 0 rgba(0, 0, 0, .15);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15);
background: linear-gradient($button-background-gradient);
display: inline-block;
font-weight: bold;
color: $button-text-color;
text-shadow: 0 1px 0 #FFF;
text-shadow: 0 1px 0 #fff;
border-radius: 2px;
border: 1px solid $gray-light;
padding: .5em .9em;
padding: 0.5em 0.9em;
}
@mixin btn-hover {
box-shadow: 0 1px 0 rgba(0, 0, 0, .05);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
outline: none;
color: $button-text-color;
background: $button-background-start;
......@@ -66,7 +67,7 @@
}
@mixin btn-active {
box-shadow: inset 0 1px 0 rgba(0, 0, 0, .1);
box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.1);
background: $button-background-end;
color: #424242;
border-color: #bababa;
......@@ -75,18 +76,41 @@
@mixin btn-disabled {
box-shadow: none;
cursor: default;
background: #F0F0F0;
border-color: #CECECE;
background: #f0f0f0;
border-color: #cecece;
color: $gray-light;
}
@mixin primary-action-btn {
// note that there is currently some duplication here between
// the styling for this element and <dropdown-menu-btn>
color: $color-seashell;
background-color: $color-dove-gray;
height: 35px;
border: none;
border-radius: 2px;
font-weight: bold;
font-size: $body1-font-size;
padding-left: 12px;
padding-right: 12px;
&:disabled {
color: $gray-light;
}
&:hover:enabled {
background-color: $color-mine-shaft;
}
}
// Tint and shade functions from
// https://css-tricks.com/snippets/sass/tint-shade-functions
@function tint($color, $percent){
@function tint($color, $percent) {
@return mix(white, $color, $percent);
}
@function shade($color, $percent){
@function shade($color, $percent) {
@return mix(black, $color, $percent);
}
.annotation-publish-control {
display: flex;
&__cancel-btn {
@extend .btn--cancel;
margin-left: 5px;
font-weight: normal;
&__icon {
margin-right: 3px;
transform: translateY(10%);
}
}
// A split button with a primary submit on the left and a drop-down menu
// of related options to the right
.annotation-publish-control__btn {
$text-color: $color-seashell;
$default-background-color: $color-dove-gray;
$hover-background-color: $color-mine-shaft;
$h-padding: 9px;
$height: 35px;
$border-radius: 2px;
$arrow-indicator-width: 26px;
height: $height;
position: relative;
// Align the menu arrow correctly with the ▼ in the toggle
&-menu-arrow {
right: 5px;
}
// Make sure the menu content is wide enough to "reach" to the right-aligned
// menu arrow
&-menu-content {
min-width: 100%;
}
&-primary {
@include primary-action-btn;
// the label occupies the entire space of the button and
// shows a darker state on hover
width: 100%;
height: 100%;
text-align: left;
padding-left: $h-padding;
padding-right: $arrow-indicator-width + 8px;
}
// dropdown arrow which reveals the button's associated menu
// when clicked
&-dropdown-arrow {
position: absolute;
right: 0px;
top: 0px;
height: 100%;
width: $arrow-indicator-width;
padding-left: 0px;
padding-right: $h-padding;
margin-left: 8px;
border: none;
background-color: $color-dove-gray;
border-top-right-radius: $border-radius;
border-bottom-right-radius: $border-radius;
&:hover,
button[aria-expanded='true'] & {
// Show a hover effect on hover or if associated menu is open
background-color: $hover-background-color;
}
&:hover &-separator,
button[aria-expanded='true'] &-separator {
// hide the 1px vertical separator when the dropdown arrow
// is hovered or menu is open
background-color: $color-dove-gray;
}
// 1px vertical separator between label and dropdown arrow
&-separator {
position: absolute;
top: 0px;
bottom: 0px;
margin-top: auto;
margin-bottom: auto;
width: 1px;
height: 15px;
background-color: $color-gray;
}
// the ▼ arrow which reveals the dropdown menu when clicked
&-indicator {
color: $text-color;
position: absolute;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
line-height: $height;
text-align: center;
& > div {
transform: scaleY(0.7);
}
}
}
}
}
@mixin primary-action-btn {
// note that there is currently some duplication here between
// the styling for this element and <dropdown-menu-btn>
color: $color-seashell;
background-color: $color-dove-gray;
height: 35px;
border: none;
border-radius: 2px;
font-weight: bold;
font-size: $body1-font-size;
padding-left: 12px;
padding-right: 12px;
&:disabled {
color: $gray-light;
}
&:hover:enabled {
background-color: $color-mine-shaft;
}
}
// A dark grey button used for the primary action
// in a form
.primary-action-btn {
......
......@@ -20,6 +20,7 @@ $base-line-height: 20px;
// ----------
@import './components/annotation';
@import './components/annotation-share-dialog';
@import './components/annotation-publish-control';
@import './components/annotation-thread';
@import './components/dropdown-menu-btn';
@import './components/excerpt';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment