Unverified Commit 481aa74c authored by Kyle Keating's avatar Kyle Keating Committed by GitHub

Create TagEditor component (#1558)

Add new preact tag editor component
parent 418f2d85
'use strict';
// @ngInject
function TagEditorController(tags) {
this.onTagsChanged = function() {
tags.store(this.tagList);
const newTags = this.tagList.map(function(item) {
return item.text;
});
this.onEditTags({ tags: newTags });
const { createElement } = require('preact');
const propTypes = require('prop-types');
const { useMemo, useRef, useState } = require('preact/hooks');
const { withServices } = require('../util/service-context');
const SvgIcon = require('./svg-icon');
// Global counter used to create a unique id for each instance of a TagEditor
let datalistIdCounter = 0;
/**
* Component to edit annotation's tags.
*/
function TagEditor({ onEditTags, tags: tagsService, tagList }) {
const inputEl = useRef(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const [datalistId] = useState(() => {
++datalistIdCounter;
return `tag-editor-datalist-${datalistIdCounter}`;
});
// List of suggestions returned from the tagsService
const suggestions = useMemo(() => {
// Remove any repeated suggestions that are already tags.
const removeDuplicates = (suggestions, tags) => {
const suggestionsSet = [];
suggestions.forEach(suggestion => {
if (tags.indexOf(suggestion) < 0) {
suggestionsSet.push(suggestion);
}
});
return suggestionsSet.sort();
};
// Call filter with an empty string to return all suggestions
return removeDuplicates(tagsService.filter(''), tagList);
}, [tagsService, tagList]);
/**
* Handle changes to this annotation's tags
*/
const updateTags = tagList => {
// update suggested tags list via service
tagsService.store(tagList.map(tag => ({ text: tag })));
onEditTags({ tags: tagList });
};
this.autocomplete = function(query) {
return Promise.resolve(tags.filter(query));
/**
* Remove a tag from this annotation.
*
* @param {string} tag
*/
const removeTag = tag => {
const newTagList = [...tagList]; // make a copy
const index = newTagList.indexOf(tag);
newTagList.splice(index, 1);
updateTags(newTagList);
};
this.$onChanges = function(changes) {
if (changes.tags) {
this.tagList = changes.tags.currentValue.map(function(tag) {
return { text: tag };
});
/**
* Adds a tag to the annotation equal to the value of the input field
* and then clears out the suggestions list and the input field.
*/
const addTag = () => {
const value = inputEl.current.value.trim();
if (value.length === 0) {
// don't add an empty tag
return;
}
if (tagList.indexOf(value) >= 0) {
// don't add duplicate tag
return;
}
updateTags([...tagList, value]);
setShowSuggestions(false);
inputEl.current.value = '';
inputEl.current.focus();
};
/**
* If the user pressed enter or comma while focused on
* the <input>, then add a new tag.
*/
const handleKeyPress = e => {
if (e.key === 'Enter' || e.key === ',') {
addTag();
// preventDefault stops the delimiter from being
// added to the input field
e.preventDefault();
}
};
const handleOnInput = e => {
if (e.inputType === 'insertText') {
// Show the suggestions if the user types something into the field
setShowSuggestions(true);
} else if (
e.inputType === undefined ||
e.inputType === 'insertReplacementText'
) {
// nb. Chrome / Safari reports undefined and Firefox reports 'insertReplacementText'
// for the inputTyp value when clicking on an element in the datalist.
//
// There are two ways to arrive here, either click an item with the mouse
// or use keyboard navigation and press 'Enter'.
//
// If the input value typed already exactly matches the option selected
// then this event won't fire and a user would have to press 'Enter' a second
// time to trigger the handleKeyPress callback above to add the tag.
// Bug: https://github.com/hypothesis/client/issues/1604
addTag();
} else if (inputEl.current.value.length === 0) {
// If the user deleted input, hide suggestions. This has
// no effect in Safari and the list will stay open.
setShowSuggestions(false);
}
};
const handleKeyUp = e => {
// Safari on macOS and iOS have an issue where pressing "Enter" in an
// input when its value exactly matches a suggestion in the associated <datalist>
// does not generate a "keypress" event. Therefore we catch the subsequent
// "keyup" event instead.
if (e.key === 'Enter') {
// nb. `addTag` will do nothing if the "keypress" event was already handled.
addTag();
}
};
const suggestionsList = () => {
return (
<datalist
id={datalistId}
className="tag-editor__suggestions"
aria-label="Annotation suggestions"
>
{showSuggestions &&
suggestions.map(suggestion => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
);
};
return (
<section className="tag-editor">
<ul
className="tag-editor__tag-list"
aria-label="Suggested tags for annotation"
>
{tagList.map(tag => {
return (
<li
key={`${tag}`}
className="tag-editor__tag-item"
aria-label={`Tag: ${tag}`}
>
<span className="tag-editor__edit">{tag}</span>
<button
onClick={() => {
removeTag(tag);
}}
title={`Remove Tag: ${tag}`}
className="tag-editor__delete"
>
<SvgIcon name="cancel" />
</button>
</li>
);
})}
</ul>
<input
list={datalistId}
onInput={handleOnInput}
onKeyPress={handleKeyPress}
onKeyUp={handleKeyUp}
ref={inputEl}
placeholder="Add tags..."
className="tag-editor__input"
type="text"
/>
{suggestionsList()}
</section>
);
}
module.exports = {
controller: TagEditorController,
controllerAs: 'vm',
bindings: {
tags: '<',
onEditTags: '&',
},
template: require('../templates/tag-editor.html'),
/**
* @typedef Tag
* @param tag {string} - The tag text
*/
TagEditor.propTypes = {
/**
* Callback that saves the tag list.
*
* @param {Array<Tag>} - Array of tags to save
*/
onEditTags: propTypes.func.isRequired,
/* The list of editable tags as strings. */
tagList: propTypes.array.isRequired,
/** Services */
tags: propTypes.object.isRequired,
serviceUrl: propTypes.func.isRequired,
};
TagEditor.injectedProps = ['serviceUrl', 'tags'];
module.exports = withServices(TagEditor);
......@@ -60,10 +60,10 @@ function TagList({ annotation, serviceUrl, settings, tags }) {
TagList.propTypes = {
/* Annotation that owns the tags. */
annotation: propTypes.object,
annotation: propTypes.object.isRequired,
/* List of tags as strings. */
tags: propTypes.array,
tags: propTypes.array.isRequired,
/** Services */
serviceUrl: propTypes.func,
......
'use strict';
const angular = require('angular');
const { createElement } = require('preact');
const { mount } = require('enzyme');
const util = require('../../directive/test/util');
const mockImportedComponents = require('./mock-imported-components');
const TagEditor = require('../tag-editor');
describe('tagEditor', function() {
let fakeTags;
describe('TagEditor', function() {
let fakeTags = ['tag1', 'tag2'];
let fakeTagsService;
let fakeServiceUrl;
let fakeOnEditTags;
before(function() {
angular.module('app', []).component('tagEditor', require('../tag-editor'));
});
function createComponent(props) {
return mount(
<TagEditor
// props
onEditTags={fakeOnEditTags}
tagList={fakeTags}
// service props
serviceUrl={fakeServiceUrl}
tags={fakeTagsService}
{...props}
/>
);
}
// Simulates a selection event from datalist
function selectOption(wrapper) {
wrapper.find('input').simulate('input', { inputType: undefined });
}
// Simulates a selection event from datalist. This is the
// only event that fires in in Safari. In Chrome, both the `keyup`
// and `input` events fire, but only the first one adds the tag.
function selectOptionViaKeyUp(wrapper) {
wrapper.find('input').simulate('keyup', { key: 'Enter' });
}
// Simulates a typing input event
function typeInput(wrapper) {
wrapper.find('input').simulate('input', { inputType: 'insertText' });
}
beforeEach(function() {
fakeTags = {
filter: sinon.stub(),
fakeOnEditTags = sinon.stub();
fakeServiceUrl = sinon.stub().returns('http://serviceurl.com');
fakeTagsService = {
filter: sinon.stub().returns(['tag4', 'tag3']),
store: sinon.stub(),
};
angular.mock.module('app', {
tags: fakeTags,
TagEditor.$imports.$mock(mockImportedComponents());
});
afterEach(() => {
TagEditor.$imports.$restore();
});
it('adds appropriate tag values to the elements', () => {
const wrapper = createComponent();
wrapper.find('li').forEach((tag, i) => {
assert.isTrue(tag.hasClass('tag-editor__tag-item'));
assert.equal(tag.text(), fakeTags[i]);
assert.equal(tag.prop('aria-label'), `Tag: ${fakeTags[i]}`);
});
});
it('converts tags to the form expected by ng-tags-input', function() {
const element = util.createDirective(document, 'tag-editor', {
tags: ['foo', 'bar'],
it("creates a `list` prop on the input that matches the datalist's `id`", () => {
const wrapper = createComponent();
assert.equal(
wrapper.find('input').prop('list'),
wrapper.find('datalist').prop('id')
);
});
it('creates multiple TagEditors with unique datalist `id`s', () => {
const wrapper1 = createComponent();
const wrapper2 = createComponent();
assert.notEqual(
wrapper1.find('datalist').prop('id'),
wrapper2.find('datalist').prop('id')
);
});
it('generates a ordered datalist containing the array values returned from fakeTagsService.filter ', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'non-empty';
wrapper.find('input').simulate('input', { inputType: 'insertText' });
// fakeTagsService.filter returns ['tag4', 'tag3'], but
// datalist shall be ordered as ['tag3', 'tag4']
assert.equal(
wrapper
.find('datalist option')
.at(0)
.prop('value'),
'tag3'
);
assert.equal(
wrapper
.find('datalist option')
.at(1)
.prop('value'),
'tag4'
);
});
[
{
text: ' in Chrome and Safari',
eventPayload: { inputType: undefined },
},
{
text: ' in Firefox',
eventPayload: { inputType: 'insertReplacementText' },
},
].forEach(test => {
it(`clears the suggestions when selecting a tag from datalist ${test.text}`, () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'tag3';
typeInput(wrapper);
assert.equal(wrapper.find('datalist option').length, 2);
wrapper.find('input').simulate('input', test.eventPayload); // simulates a selection
assert.equal(wrapper.find('datalist option').length, 0);
assert.isTrue(
fakeTagsService.store.calledWith([
{ text: 'tag1' },
{ text: 'tag2' },
{ text: 'tag3' },
])
);
});
assert.deepEqual(element.ctrl.tagList, [{ text: 'foo' }, { text: 'bar' }]);
});
describe('when tags are changed', function() {
let element;
let onEditTags;
it('clears the suggestions when deleting input', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'tag3';
typeInput(wrapper);
assert.equal(wrapper.find('datalist option').length, 2);
wrapper.find('input').instance().value = '';
wrapper
.find('input')
.simulate('input', { inputType: 'deleteContentBackward' });
assert.equal(wrapper.find('datalist option').length, 0);
});
beforeEach(function() {
onEditTags = sinon.stub();
element = util.createDirective(document, 'tag-editor', {
onEditTags: { args: ['tags'], callback: onEditTags },
tags: ['foo'],
});
element.ctrl.onTagsChanged();
it('does not clear the suggestions when deleting only part of the input', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'tag3';
typeInput(wrapper);
assert.equal(wrapper.find('datalist option').length, 2);
wrapper.find('input').instance().value = 't'; // non-empty input remains
wrapper
.find('input')
.simulate('input', { inputType: 'deleteContentBackward' });
assert.notEqual(wrapper.find('datalist option').length, 0);
});
it('does not render duplicate suggestions', () => {
// `tag3` supplied in the `tagList` will be a duplicate value relative
// with the fakeTagsService.filter result above.
const wrapper = createComponent({
editMode: true,
tagList: ['tag1', 'tag2', 'tag3'],
});
wrapper.find('input').instance().value = 'non-empty';
typeInput(wrapper);
assert.equal(wrapper.find('datalist option').length, 1);
assert.equal(
wrapper
.find('datalist option')
.at(0)
.prop('value'),
'tag4'
);
});
it('calls onEditTags handler', function() {
assert.calledWith(onEditTags, sinon.match(['foo']));
context('when adding tags', () => {
/**
* Helper function to assert that a tag was correctly added
*/
const assertAddTagsSuccess = (wrapper, tagList) => {
// saves the suggested tags to the service
assert.isTrue(
fakeTagsService.store.calledWith(tagList.map(tag => ({ text: tag })))
);
// called the onEditTags callback prop
assert.isTrue(fakeOnEditTags.calledWith({ tags: tagList }));
// clears out the suggestions
assert.equal(wrapper.find('datalist option').length, 0);
// assert the input value is cleared out
assert.equal(wrapper.find('input').instance().value, '');
// note: focus not tested
};
/**
* Helper function to assert that a tag was correctly not added
*/
const assertAddTagsFail = () => {
assert.isTrue(fakeTagsService.store.notCalled);
assert.isTrue(fakeOnEditTags.notCalled);
};
it('adds a tag from the input field', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'tag3';
selectOption(wrapper);
assertAddTagsSuccess(wrapper, ['tag1', 'tag2', 'tag3']);
});
it('saves tags to the store', function() {
assert.calledWith(fakeTags.store, sinon.match([{ text: 'foo' }]));
it('adds a tag from the input field via keyup event', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'tag3';
selectOptionViaKeyUp(wrapper);
assertAddTagsSuccess(wrapper, ['tag1', 'tag2', 'tag3']);
});
});
describe('#autocomplete', function() {
it('suggests tags using the `tags` service', function() {
const element = util.createDirective(document, 'tag-editor', {
tags: [],
it('populate the datalist, then adds a tag from the input field', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'tag3';
typeInput(wrapper);
assert.equal(wrapper.find('datalist option').length, 2);
selectOption(wrapper);
assertAddTagsSuccess(wrapper, ['tag1', 'tag2', 'tag3']);
});
it('clears out the <option> elements after adding a tag', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'non-empty';
typeInput(wrapper);
assert.equal(wrapper.find('datalist option').length, 2);
selectOption(wrapper);
assert.equal(wrapper.find('datalist option').length, 0);
});
it('should not add a tag if the input is empty', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = '';
selectOption(wrapper);
assertAddTagsFail();
});
it('should not add a tag if the input is blank space', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = ' ';
selectOption(wrapper);
assertAddTagsFail();
});
it('should not add a tag if its a duplicate of one already in the list', () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'tag1';
selectOption(wrapper);
assertAddTagsFail();
});
[
{
key: 'Enter',
text: 'adds a tag via keypress `Enter`',
run: wrapper => {
assertAddTagsSuccess(wrapper, ['tag1', 'tag2', 'tag3']);
},
},
{
key: ',',
text: 'adds a tag via keypress `,`',
run: wrapper => {
assertAddTagsSuccess(wrapper, ['tag1', 'tag2', 'tag3']);
},
},
{
key: 'e',
text: 'does not add a tag when key is not `,` or `Enter`',
run: () => {
assertAddTagsFail();
},
},
].forEach(test => {
it(test.text, () => {
const wrapper = createComponent();
wrapper.find('input').instance().value = 'tag3';
wrapper.find('input').simulate('keypress', { key: test.key });
test.run(wrapper);
});
element.ctrl.autocomplete('query');
assert.calledWith(fakeTags.filter, 'query');
});
});
context('when removing tags', () => {
it('removes `tag1` when clicking its delete button', () => {
const wrapper = createComponent(); // note: initial tagList is ['tag1', 'tag2']
assert.equal(wrapper.find('.tag-editor__edit').length, 2);
wrapper
.find('button')
.at(0) // delete 'tag1'
.simulate('click');
// saves the suggested tags to the service (only 'tag2' should be passed)
assert.isTrue(fakeTagsService.store.calledWith([{ text: 'tag2' }]));
// called the onEditTags callback prop (only 'tag2' should be passed)
assert.isTrue(fakeOnEditTags.calledWith({ tags: ['tag2'] }));
});
});
});
......@@ -194,7 +194,10 @@ function startAngularApp(config) {
)
.component('streamContent', require('./components/stream-content'))
.component('svgIcon', wrapReactComponent(require('./components/svg-icon')))
.component('tagEditor', require('./components/tag-editor'))
.component(
'tagEditor',
wrapReactComponent(require('./components/tag-editor'))
)
.component('tagList', wrapReactComponent(require('./components/tag-list')))
.component('threadList', require('./components/thread-list'))
.component('topBar', wrapReactComponent(require('./components/top-bar')))
......
......@@ -37,12 +37,16 @@
h-branding="accentColor">mire</a>
</div>
<!-- Tags -->
<div class="annotation-body" ng-if="vm.editing()">
<tag-editor tags="vm.state().tags"
on-edit-tags="vm.setTags(tags)"></tag-editor>
</div>
<tag-editor
ng-if="vm.editing()"
annotation="vm.annotation"
on-edit-tags="vm.setTags(tags)"
tag-list="vm.state().tags"
/>
</tag-editor>
<tag-list
ng-if="!vm.editing()"
annotation="vm.annotation"
tags="vm.state().tags"
></tag-list>
......
<tags-input ng-model="vm.tagList"
name="tags"
class="tags"
placeholder="Add tags…"
min-length="1"
replace-spaces-with-dashes="false"
enable-editing-last-tag="true"
on-tag-added="vm.onTagsChanged()"
on-tag-removed="vm.onTagsChanged()">
<auto-complete source="vm.autocomplete($query)"
min-length="1"
max-results-to-show="10"></auto-complete>
</tags-input>
@use "../../mixins/forms";
@use "../../mixins/buttons";
@use "../../variables" as var;
.tag-editor {
&__input {
@include forms.form-input;
width: 100%;
&:focus {
@include forms.form-input-focus;
}
}
&__tag-list {
display: flex;
flex-wrap: wrap;
}
&__tag-item {
display: flex;
margin-right: 0.5em;
margin-bottom: 0.5em;
}
&__edit {
color: var.$grey-6;
background: var.$grey-1;
border: 1px solid var.$grey-3;
border-radius: 2px 0 0 2px;
border-right-width: 0;
padding: 0 0.5em;
}
&__delete {
@include buttons.button-base;
background-color: var.$grey-1;
padding: 0 0.5em;
border: 1px solid var.$grey-3;
border-radius: 0 2px 2px 0;
&:hover {
background-color: var.$grey-2;
}
}
}
......@@ -60,6 +60,7 @@
@use './components/sidebar-panel';
@use './components/svg-icon';
@use './components/spinner';
@use './components/tag-editor';
@use './components/tag-list';
@use './components/tags-input';
@use './components/thread-list';
......
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