Unverified Commit efa76f3a authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1702 from hypothesis/annotation-body

Re-factor props for `AnnotationBody` and add `AnnotationBody` to `AnnotationOmega`
parents a20e0c9c 1af89106
import { createElement } from 'preact';
import { Fragment, createElement } from 'preact';
import { useState } from 'preact/hooks';
import propTypes from 'prop-types';
import { isHidden } from '../util/annotation-metadata';
import Button from './button';
import Excerpt from './excerpt';
import MarkdownEditor from './markdown-editor';
import MarkdownView from './markdown-view';
......@@ -9,82 +13,78 @@ import MarkdownView from './markdown-view';
* Display the rendered content of an annotation.
*/
export default function AnnotationBody({
collapse,
annotation,
isEditing,
isHiddenByModerator,
onCollapsibleChanged,
onEditText,
onToggleCollapsed,
text,
}) {
// Should the text content of `Excerpt` be rendered in a collapsed state,
// assuming it is collapsible (exceeds allotted collapsed space)?
const [isCollapsed, setIsCollapsed] = useState(true);
// Does the text content of `Excerpt` take up enough vertical space that
// collapsing/expanding is relevant?
const [isCollapsible, setIsCollapsible] = useState(false);
const toggleText = isCollapsed ? 'More' : 'Less';
const toggleTitle = isCollapsed
? 'Show full annotation text'
: 'Show the first few lines only';
return (
<section className="annotation-body">
{!isEditing && (
<Excerpt
collapse={collapse}
collapsedHeight={400}
inlineControls={false}
onCollapsibleChanged={onCollapsibleChanged}
onToggleCollapsed={collapsed => onToggleCollapsed({ collapsed })}
overflowThreshold={20}
>
<MarkdownView
markdown={text}
textClass={{
'annotation-body__text': true,
'is-hidden': isHiddenByModerator,
'has-content': text.length > 0,
}}
<Fragment>
<section className="annotation-body">
{!isEditing && (
<Excerpt
collapse={isCollapsed}
collapsedHeight={400}
inlineControls={false}
onCollapsibleChanged={setIsCollapsible}
onToggleCollapsed={setIsCollapsed}
overflowThreshold={20}
>
<MarkdownView
markdown={text}
textClass={{
'annotation-body__text': true,
'is-hidden': isHidden(annotation),
'has-content': text.length > 0,
}}
/>
</Excerpt>
)}
{isEditing && <MarkdownEditor text={text} onEditText={onEditText} />}
</section>
{isCollapsible && !isEditing && (
<div className="annotation-body__collapse-toggle">
<Button
className="annotation-body__collapse-toggle-button"
onClick={() => setIsCollapsed(!isCollapsed)}
buttonText={toggleText}
title={toggleTitle}
/>
</Excerpt>
</div>
)}
{isEditing && <MarkdownEditor text={text} onEditText={onEditText} />}
</section>
</Fragment>
);
}
AnnotationBody.propTypes = {
/**
* Whether to limit the height of the annotation body.
*
* If this is true and the intrinsic height exceeds a fixed threshold, the
* body is truncated. See `onCollapsibleChanged` and `onToggleCollapsed`.
* The annotation in question
*/
collapse: propTypes.bool,
annotation: propTypes.object.isRequired,
/**
* Whether to display the body in edit mode (if true) or view mode.
*/
isEditing: propTypes.bool,
/**
* `true` if the contents of this annotation body have been redacted by
* a moderator.
*
* For redacted annotations, the text is shown struck-through (if available)
* or replaced by a placeholder indicating redacted content (if `text` is
* empty).
*/
isHiddenByModerator: propTypes.bool,
/**
* Callback invoked when the height of the rendered annotation body increases
* above or falls below the threshold at which the `collapse` prop will affect
* it.
*/
onCollapsibleChanged: propTypes.func,
/**
* Callback invoked when the user edits the content of the annotation body.
*/
onEditText: propTypes.func,
/**
* Callback invoked when the user clicks a shaded area at the bottom of a
* truncated body to indicate that they want to see the rest of the content.
*/
onToggleCollapsed: propTypes.func,
/**
* The markdown annotation body, which is either rendered as HTML (if `isEditing`
* is false) or displayed in a text area otherwise.
......
......@@ -4,6 +4,7 @@ import propTypes from 'prop-types';
import useStore from '../store/use-store';
import { quote } from '../util/annotation-metadata';
import AnnotationBody from './annotation-body';
import AnnotationHeader from './annotation-header';
import AnnotationQuote from './annotation-quote';
......@@ -25,6 +26,18 @@ function AnnotationOmega({
// TODO: `isEditing` will also take into account `isSaving`
const isEditing = !!draft;
const annotationState = () =>
draft || {
text: annotation.text,
tags: annotation.tags,
annotation,
};
// TODO
const fakeOnEditText = () => {
alert('TBD');
};
return (
<div className="annotation-omega">
<AnnotationHeader
......@@ -35,6 +48,12 @@ function AnnotationOmega({
showDocumentInfo={showDocumentInfo}
/>
{hasQuote && <AnnotationQuote annotation={annotation} />}
<AnnotationBody
annotation={annotation}
isEditing={isEditing}
onEditText={fakeOnEditText}
text={annotationState().text}
/>
</div>
);
}
......
......@@ -71,14 +71,6 @@ function AnnotationController(
* methods goes here.
*/
this.$onInit = () => {
/** Determines whether controls to expand/collapse the annotation body
* are displayed adjacent to the tags field.
*/
self.canCollapseBody = false;
/** Determines whether the annotation body should be collapsed. */
self.collapseBody = true;
/** True if the annotation is currently being saved. */
self.isSaving = false;
......@@ -242,11 +234,6 @@ function AnnotationController(
}
};
this.toggleCollapseBody = function(event) {
event.stopPropagation();
self.collapseBody = !self.collapseBody;
};
/**
* @ngdoc method
* @name annotation.AnnotationController#reply
......@@ -364,25 +351,10 @@ function AnnotationController(
return store.hasPendingDeletion(self.annotation.id);
};
this.isHiddenByModerator = function() {
return self.annotation.hidden;
};
this.isReply = function() {
return isReply(self.annotation);
};
/**
* Sets whether or not the controls for expanding/collapsing the body of
* lengthy annotations should be shown.
*/
this.setBodyCollapsible = function(canCollapse) {
if (canCollapse === self.canCollapseBody) {
return;
}
self.canCollapseBody = canCollapse;
};
this.setText = function(text) {
store.createDraft(self.annotation, {
isPrivate: self.state().isPrivate,
......
......@@ -77,7 +77,7 @@ function Excerpt({
// prettier-ignore
const isCollapsible =
newContentHeight > (collapsedHeight + overflowThreshold);
onCollapsibleChanged({ collapsible: isCollapsible });
onCollapsibleChanged(isCollapsible);
}, [collapsedHeight, onCollapsibleChanged, overflowThreshold]);
useLayoutEffect(() => {
......
import { mount } from 'enzyme';
import { createElement } from 'preact';
import { act } from 'preact/test-utils';
import * as fixtures from '../../test/annotation-fixtures';
import AnnotationBody from '../annotation-body';
import { $imports } from '../annotation-body';
......@@ -8,7 +11,14 @@ import mockImportedComponents from './mock-imported-components';
describe('AnnotationBody', () => {
function createBody(props = {}) {
return mount(<AnnotationBody text="test comment" {...props} />);
return mount(
<AnnotationBody
annotation={fixtures.defaultAnnotation()}
isEditing={false}
text="test comment"
{...props}
/>
);
}
beforeEach(() => {
......@@ -30,4 +40,53 @@ describe('AnnotationBody', () => {
assert.isTrue(wrapper.exists('MarkdownEditor'));
assert.isFalse(wrapper.exists('MarkdownView'));
});
it('does not render controls to expand/collapse the excerpt if it is not collapsible', () => {
const wrapper = createBody();
// By default, `isCollapsible` is `false` until changed by `Excerpt`,
// so the expand/collapse button will not render
assert.notOk(wrapper.find('Button').exists());
});
it('renders controls to expand/collapse the excerpt if it is collapsible', () => {
const wrapper = createBody();
const excerpt = wrapper.find('Excerpt');
act(() => {
// change the `isCollapsible` state to `true` via the `Excerpt`
excerpt.props().onCollapsibleChanged(true);
});
wrapper.update();
const button = wrapper.find('Button');
assert.isOk(button.exists());
assert.equal(button.props().buttonText, 'More');
assert.equal(button.props().title, 'Show full annotation text');
});
it('shows appropriate button text to collapse the Excerpt if expanded', () => {
const wrapper = createBody();
const excerpt = wrapper.find('Excerpt');
act(() => {
// Get the `isCollapsible` state to `true`
excerpt.props().onCollapsibleChanged(true);
// Force a re-render so the button shows up
});
wrapper.update();
act(() => {
wrapper
.find('Button')
.props()
.onClick();
});
wrapper.update();
const buttonProps = wrapper.find('Button').props();
assert.equal(buttonProps.buttonText, 'Less');
assert.equal(buttonProps.title, 'Show the first few lines only');
});
});
......@@ -123,12 +123,8 @@ describe('annotation', function() {
.component('annotation', annotationComponent)
.component('annotationBody', {
bindings: {
collapse: '<',
isEditing: '<',
isHiddenByModerator: '<',
onCollapsibleChanged: '&',
onEditText: '&',
onToggleCollapsed: '&',
text: '<',
},
})
......@@ -807,35 +803,5 @@ describe('annotation', function() {
assert.calledWith($rootScope.$broadcast, events.ANNOTATION_DELETED);
});
});
[
{
context: 'for moderators',
ann: Object.assign(fixtures.moderatedAnnotation({ hidden: true }), {
// Content still present.
text: 'Some offensive content',
}),
isHiddenByModerator: true,
},
{
context: 'for non-moderators',
ann: Object.assign(fixtures.moderatedAnnotation({ hidden: true }), {
// Content filtered out by service.
tags: [],
text: '',
}),
isHiddenByModerator: true,
},
].forEach(({ ann, context, isHiddenByModerator }) => {
it(`passes moderation status to annotation body (${context})`, () => {
const el = createDirective(ann).element;
assert.match(
el.find('annotation-body').controller('annotationBody'),
sinon.match({
isHiddenByModerator,
})
);
});
});
});
});
......@@ -101,7 +101,7 @@ describe('Excerpt', () => {
sizeChangedCallback();
});
assert.calledWith(onCollapsibleChanged, { collapsible: true });
assert.calledWith(onCollapsibleChanged, true);
});
it('calls `onToggleCollapsed` when user clicks in bottom area to expand excerpt', () => {
......
......@@ -15,22 +15,12 @@
</annotation-quote>
<annotation-body
collapse="vm.collapseBody"
annotation="vm.annotation"
is-editing="vm.editing()"
is-hidden-by-moderator="vm.isHiddenByModerator()"
on-collapsible-changed="vm.setBodyCollapsible(collapsible)"
on-edit-text="vm.setText(text)"
on-toggle-collapsed="vm.collapseBody = collapsed"
text="vm.state().text">
</annotation-body>
<div class="annotation-link-more">
<a class="annotation-link u-strong" ng-show="vm.canCollapseBody && !vm.editing()"
ng-click="vm.toggleCollapseBody($event)"
ng-title="vm.collapseBody ? 'Show the full annotation text' : 'Show the first few lines only'"
ng-bind="vm.collapseBody ? 'More' : 'Less'"
h-branding="accentColor">more</a>
</div>
<!-- Tags -->
<tag-editor
ng-if="vm.editing()"
......
......@@ -149,6 +149,16 @@ export function isWaitingToAnchor(annotation) {
);
}
/**
* Has this annotation hidden by moderators?
*
* @param {Object} annotation
* @return {boolean}
*/
export function isHidden(annotation) {
return !!annotation.hidden;
}
/**
* Is this annotation a highlight?
*
......
......@@ -234,6 +234,24 @@ describe('annotation-metadata', function() {
});
});
describe('.isHidden', () => {
it('returns `true` if annotation has been hidden', () => {
const annotation = fixtures.moderatedAnnotation({ hidden: true });
assert.isTrue(annotationMetadata.isHidden(annotation));
});
[
fixtures.newEmptyAnnotation(),
fixtures.newReply(),
fixtures.newHighlight(),
fixtures.oldAnnotation(),
].forEach(nonHiddenAnnotation => {
it('returns `false` if annotation is not hidden', () => {
assert.isFalse(annotationMetadata.isHidden(nonHiddenAnnotation));
});
});
});
describe('.isHighlight', () => {
[
{
......
......@@ -36,3 +36,14 @@
);
}
}
.annotation-body__collapse-toggle {
// Negative top margin to bring this up tight under `.annotation-body`
margin-top: -(var.$layout-h-margin - 5px);
display: flex;
justify-content: flex-end;
.annotation-body__collapse-toggle-button {
background-color: transparent;
}
}
......@@ -20,7 +20,7 @@ $grey-4: #a6a6a6;
// minus blue tint.
$grey-semi: #9c9c9c;
$grey-5: #7a7a7a;
$grey-5: #767676;
// Interim color variable for migration purposes, as the step between `$grey-5`
// and `$grey-6` is large. Represents `base-mid` in proposed future palette,
......
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