Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
a965d91e
Unverified
Commit
a965d91e
authored
Mar 27, 2020
by
Lyza Gardner
Committed by
GitHub
Mar 27, 2020
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #1964 from hypothesis/preact-thread-component
Replace `annotation-thread` with preact `Thread` component
parents
4db9f2a3
a47da4ae
Changes
16
Hide whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
573 additions
and
509 deletions
+573
-509
build-thread.js
src/sidebar/build-thread.js
+1
-1
annotation-thread.js
src/sidebar/components/annotation-thread.js
+0
-106
annotation.js
src/sidebar/components/annotation.js
+4
-4
annotation-thread-test.js
src/sidebar/components/test/annotation-thread-test.js
+0
-258
thread-list-test.js
src/sidebar/components/test/thread-list-test.js
+2
-2
thread-test.js
src/sidebar/components/test/thread-test.js
+286
-0
thread.js
src/sidebar/components/thread.js
+113
-0
index.js
src/sidebar/index.js
+5
-2
threads-test.js
src/sidebar/services/test/threads-test.js
+89
-0
threads.js
src/sidebar/services/threads.js
+18
-0
annotation-thread.html
src/sidebar/templates/annotation-thread.html
+0
-54
thread-list.html
src/sidebar/templates/thread-list.html
+1
-5
annotation-header.scss
src/styles/sidebar/components/annotation-header.scss
+1
-0
annotation-thread.scss
src/styles/sidebar/components/annotation-thread.scss
+0
-76
thread.scss
src/styles/sidebar/components/thread.scss
+52
-0
sidebar.scss
src/styles/sidebar/sidebar.scss
+1
-1
No files found.
src/sidebar/build-thread.js
View file @
a965d91e
...
...
@@ -264,7 +264,7 @@ const defaultOpts = {
/**
* Project, filter and sort a list of annotations into a thread structure for
* display by the <
annotation-thread> directive
.
* display by the <
Thread> component
.
*
* buildThread() takes as inputs a flat list of annotations,
* the current visibility filters and sort function and returns
...
...
src/sidebar/components/annotation-thread.js
deleted
100644 → 0
View file @
4db9f2a3
import
{
countVisible
,
countHidden
}
from
'../util/thread'
;
function
showAllChildren
(
thread
,
showFn
)
{
thread
.
children
.
forEach
(
child
=>
{
showFn
(
child
);
showAllChildren
(
child
,
showFn
);
});
}
function
showAllParents
(
thread
,
showFn
)
{
while
(
thread
.
parent
&&
thread
.
parent
.
annotation
)
{
showFn
(
thread
.
parent
);
thread
=
thread
.
parent
;
}
}
// @ngInject
function
AnnotationThreadController
(
features
,
store
)
{
// Flag that tracks whether the content of the annotation is hovered,
// excluding any replies.
this
.
annotationHovered
=
false
;
this
.
toggleCollapsed
=
function
()
{
this
.
onChangeCollapsed
({
id
:
this
.
thread
.
id
,
collapsed
:
!
this
.
thread
.
collapsed
,
});
};
this
.
threadClasses
=
function
()
{
return
{
'annotation-thread'
:
true
,
'annotation-thread--reply'
:
this
.
thread
.
depth
>
0
,
'annotation-thread--top-reply'
:
this
.
thread
.
depth
===
1
,
};
};
this
.
threadToggleClasses
=
function
()
{
return
{
'annotation-thread__collapse-toggle'
:
true
,
'is-open'
:
!
this
.
thread
.
collapsed
,
'is-hovered'
:
this
.
annotationHovered
,
};
};
this
.
annotationClasses
=
function
()
{
return
{
annotation
:
true
,
'annotation--reply'
:
this
.
thread
.
depth
>
0
,
'is-collapsed'
:
this
.
thread
.
collapsed
,
'is-highlighted'
:
this
.
thread
.
highlightState
===
'highlight'
,
'is-dimmed'
:
this
.
thread
.
highlightState
===
'dim'
,
};
};
/**
* Show this thread and any of its children. This is available if filtering
* is applied that hides items in the thread.
*/
this
.
showThreadAndReplies
=
function
()
{
showAllParents
(
this
.
thread
,
this
.
onForceVisible
);
this
.
onForceVisible
(
this
.
thread
);
showAllChildren
(
this
.
thread
,
this
.
onForceVisible
);
};
this
.
isTopLevelThread
=
function
()
{
return
!
this
.
thread
.
parent
;
};
/**
* Return the total number of annotations in the current
* thread which have been hidden because they do not match the current
* search filter.
*/
this
.
hiddenCount
=
function
()
{
return
countHidden
(
this
.
thread
);
};
this
.
shouldShowReply
=
function
(
child
)
{
return
countVisible
(
child
)
>
0
;
};
this
.
onForceVisible
=
function
(
thread
)
{
store
.
setForceVisible
(
thread
.
id
,
true
);
if
(
thread
.
parent
)
{
store
.
setCollapsed
(
thread
.
parent
.
id
,
false
);
}
};
}
export
default
{
controllerAs
:
'vm'
,
controller
:
AnnotationThreadController
,
bindings
:
{
/** The annotation thread to render. */
thread
:
'<'
,
/**
* Specify whether document information should be shown
* on annotation cards.
*/
showDocumentInfo
:
'<'
,
/** Called when the user clicks on the expand/collapse replies toggle. */
onChangeCollapsed
:
'&'
,
},
template
:
require
(
'../templates/annotation-thread.html'
),
};
src/sidebar/components/annotation.js
View file @
a965d91e
...
...
@@ -117,15 +117,15 @@ function Annotation({
{
isEditing
&&
<
TagEditor
onEditTags
=
{
onEditTags
}
tagList
=
{
tags
}
/>
}
{
!
isEditing
&&
<
TagList
annotation
=
{
annotation
}
tags
=
{
tags
}
/>
}
<
footer
className
=
"annotation__footer"
>
<
div
className
=
"annotation__form-actions"
>
{
isEditing
&&
(
{
isEditing
&&
(
<
div
className
=
"annotation__form-actions"
>
<
AnnotationPublishControl
annotation
=
{
annotation
}
isDisabled
=
{
isEmpty
}
onSave
=
{
onSave
}
/
>
)}
<
/div
>
<
/div
>
)}
{
shouldShowLicense
&&
<
AnnotationLicense
/>
}
<
div
className
=
"annotation__controls"
>
{
shouldShowReplyToggle
&&
(
...
...
src/sidebar/components/test/annotation-thread-test.js
deleted
100644 → 0
View file @
4db9f2a3
import
angular
from
'angular'
;
import
*
as
util
from
'./angular-util'
;
import
*
as
fixtures
from
'../../test/annotation-fixtures'
;
import
annotationThread
from
'../annotation-thread'
;
import
moderationBanner
from
'../moderation-banner'
;
function
PageObject
(
element
)
{
this
.
annotations
=
function
()
{
return
Array
.
from
(
element
[
0
].
querySelectorAll
(
'annotation'
));
};
this
.
visibleReplies
=
function
()
{
return
Array
.
from
(
element
[
0
].
querySelectorAll
(
'.annotation-thread__content > ul > li:not(.ng-hide)'
)
);
};
this
.
replyList
=
function
()
{
return
element
[
0
].
querySelector
(
'.annotation-thread__content > ul'
);
};
this
.
isHidden
=
function
(
element
)
{
return
element
.
classList
.
contains
(
'ng-hide'
);
};
}
describe
(
'annotationThread'
,
function
()
{
before
(
function
()
{
angular
.
module
(
'app'
,
[])
.
component
(
'annotationThread'
,
annotationThread
)
.
component
(
'moderationBanner'
,
{
bindings
:
moderationBanner
.
bindings
,
});
});
let
fakeFeatures
;
let
fakeStore
;
beforeEach
(
function
()
{
fakeFeatures
=
{
flagEnabled
:
sinon
.
stub
().
returns
(
false
),
};
fakeStore
=
{
setForceVisible
:
sinon
.
stub
(),
setCollapsed
:
sinon
.
stub
(),
getState
:
sinon
.
stub
(),
};
angular
.
mock
.
module
(
'app'
,
{
features
:
fakeFeatures
,
store
:
fakeStore
});
});
it
(
'renders the tree structure of parent and child annotations'
,
function
()
{
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
{
id
:
'1'
,
annotation
:
{
id
:
'1'
,
text
:
'text'
},
children
:
[
{
id
:
'2'
,
annotation
:
{
id
:
'2'
,
text
:
'areply'
},
children
:
[],
visible
:
true
,
},
],
visible
:
true
,
},
});
const
pageObject
=
new
PageObject
(
element
);
assert
.
equal
(
pageObject
.
annotations
().
length
,
2
);
assert
.
equal
(
pageObject
.
visibleReplies
().
length
,
1
);
});
it
(
'does not render hidden threads'
,
function
()
{
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
{
id
:
'1'
,
annotation
:
{
id
:
'1'
},
visible
:
false
,
children
:
[],
},
});
const
pageObject
=
new
PageObject
(
element
);
assert
.
equal
(
pageObject
.
annotations
().
length
,
0
);
});
describe
(
'onForceVisible'
,
()
=>
{
it
(
'shows the thread'
,
()
=>
{
const
thread
=
{
id
:
'1'
,
children
:
[],
};
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
thread
,
});
element
.
ctrl
.
onForceVisible
(
thread
);
assert
.
calledWith
(
fakeStore
.
setForceVisible
,
thread
.
id
,
true
);
});
it
(
'uncollapses the parent'
,
()
=>
{
const
thread
=
{
id
:
'2'
,
children
:
[],
parent
:
{
id
:
'3'
},
};
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
thread
,
});
element
.
ctrl
.
onForceVisible
(
thread
);
assert
.
calledWith
(
fakeStore
.
setCollapsed
,
thread
.
parent
.
id
,
false
);
});
});
it
(
'shows replies if not collapsed'
,
function
()
{
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
{
id
:
'1'
,
annotation
:
{
id
:
'1'
},
visible
:
true
,
children
:
[
{
id
:
'2'
,
annotation
:
{
id
:
'2'
},
children
:
[],
visible
:
true
,
},
],
collapsed
:
false
,
},
});
const
pageObject
=
new
PageObject
(
element
);
assert
.
isFalse
(
pageObject
.
isHidden
(
pageObject
.
replyList
()));
});
it
(
'does not show replies if collapsed'
,
function
()
{
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
{
id
:
'1'
,
annotation
:
{
id
:
'1'
},
visible
:
true
,
children
:
[
{
id
:
'2'
,
annotation
:
{
id
:
'2'
},
children
:
[],
visible
:
true
,
},
],
collapsed
:
true
,
},
});
const
pageObject
=
new
PageObject
(
element
);
assert
.
isTrue
(
pageObject
.
isHidden
(
pageObject
.
replyList
()));
});
it
(
'only shows replies that match the search filter'
,
function
()
{
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
{
id
:
'1'
,
annotation
:
{
id
:
'1'
},
visible
:
true
,
children
:
[
{
id
:
'2'
,
annotation
:
{
id
:
'2'
},
children
:
[],
visible
:
false
,
},
{
id
:
'3'
,
annotation
:
{
id
:
'3'
},
children
:
[],
visible
:
true
,
},
],
collapsed
:
false
,
},
});
const
pageObject
=
new
PageObject
(
element
);
assert
.
equal
(
pageObject
.
visibleReplies
().
length
,
1
);
});
describe
(
'#toggleCollapsed'
,
function
()
{
it
(
'toggles replies'
,
function
()
{
const
onChangeCollapsed
=
sinon
.
stub
();
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
{
id
:
'123'
,
annotation
:
{
id
:
'123'
},
children
:
[],
collapsed
:
true
,
},
onChangeCollapsed
:
{
args
:
[
'id'
,
'collapsed'
],
callback
:
onChangeCollapsed
,
},
});
element
.
ctrl
.
toggleCollapsed
();
assert
.
calledWith
(
onChangeCollapsed
,
'123'
,
false
);
});
});
describe
(
'#showThreadAndReplies'
,
function
()
{
it
(
'reveals all parents and replies'
,
function
()
{
const
thread
=
{
id
:
'123'
,
annotation
:
{
id
:
'123'
},
children
:
[
{
id
:
'child-id'
,
annotation
:
{
id
:
'child-id'
},
children
:
[],
},
],
parent
:
{
id
:
'parent-id'
,
annotation
:
{
id
:
'parent-id'
},
},
};
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
thread
,
});
element
.
ctrl
.
showThreadAndReplies
();
assert
.
calledWith
(
fakeStore
.
setForceVisible
,
thread
.
parent
.
id
,
true
);
assert
.
calledWith
(
fakeStore
.
setForceVisible
,
thread
.
id
,
true
);
assert
.
calledWith
(
fakeStore
.
setForceVisible
,
thread
.
children
[
0
].
id
,
true
);
assert
.
calledWith
(
fakeStore
.
setCollapsed
,
thread
.
parent
.
id
,
false
);
});
});
it
(
'renders the moderation banner'
,
function
()
{
const
ann
=
fixtures
.
moderatedAnnotation
({
flagCount
:
1
});
const
thread
=
{
annotation
:
ann
,
id
:
'123'
,
parent
:
null
,
children
:
[],
};
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
thread
,
});
assert
.
ok
(
element
[
0
].
querySelector
(
'moderation-banner'
));
});
it
(
'does not render the annotation or moderation banner if there is no annotation'
,
function
()
{
const
thread
=
{
annotation
:
null
,
id
:
'123'
,
parent
:
null
,
children
:
[],
};
const
element
=
util
.
createDirective
(
document
,
'annotationThread'
,
{
thread
:
thread
,
});
assert
.
notOk
(
element
[
0
].
querySelector
(
'moderation-banner'
));
assert
.
notOk
(
element
[
0
].
querySelector
(
'annotation'
));
});
});
src/sidebar/components/test/thread-list-test.js
View file @
a965d91e
...
...
@@ -160,7 +160,7 @@ describe('threadList', function() {
element
.
scope
.
$digest
();
assert
.
equal
(
element
[
0
].
querySelectorAll
(
'.thread-list__card--theme-clean'
).
length
,
element
[
0
].
querySelectorAll
(
'
annotation-
thread'
).
length
element
[
0
].
querySelectorAll
(
'thread'
).
length
);
});
...
...
@@ -168,7 +168,7 @@ describe('threadList', function() {
const
element
=
createThreadList
();
fakeVirtualThread
.
notify
();
element
.
scope
.
$digest
();
const
children
=
element
[
0
].
querySelectorAll
(
'
annotation-
thread'
);
const
children
=
element
[
0
].
querySelectorAll
(
'thread'
);
assert
.
equal
(
children
.
length
,
2
);
});
...
...
src/sidebar/components/test/thread-test.js
0 → 100644
View file @
a965d91e
import
{
mount
}
from
'enzyme'
;
import
{
createElement
}
from
'preact'
;
import
{
act
}
from
'preact/test-utils'
;
import
Thread
from
'../thread'
;
import
{
$imports
}
from
'../thread'
;
import
{
checkAccessibility
}
from
'../../../test-util/accessibility'
;
import
mockImportedComponents
from
'../../../test-util/mock-imported-components'
;
// Utility functions to build nested threads
let
lastThreadId
=
0
;
const
createThread
=
()
=>
{
lastThreadId
++
;
return
{
id
:
lastThreadId
.
toString
(),
annotation
:
{},
children
:
[],
parent
:
undefined
,
collapsed
:
false
,
visible
:
true
,
depth
:
0
,
replyCount
:
0
,
};
};
const
addChildThread
=
parent
=>
{
const
childThread
=
createThread
();
childThread
.
parent
=
parent
.
id
;
parent
.
children
.
push
(
childThread
);
return
childThread
;
};
// NB: This logic lifted from `build-thread.js`
function
countRepliesAndDepth
(
thread
,
depth
)
{
const
children
=
thread
.
children
.
map
(
child
=>
{
return
countRepliesAndDepth
(
child
,
depth
+
1
);
});
return
{
...
thread
,
children
,
depth
,
replyCount
:
children
.
reduce
((
total
,
child
)
=>
{
return
total
+
1
+
child
.
replyCount
;
},
0
),
};
}
/**
* Utility function: construct a thread with several children
*/
const
buildThreadWithChildren
=
()
=>
{
let
thread
=
createThread
();
addChildThread
(
thread
);
addChildThread
(
thread
);
addChildThread
(
thread
.
children
[
0
]);
addChildThread
(
thread
.
children
[
0
].
children
[
0
]);
addChildThread
(
thread
.
children
[
1
]);
// `depth` and `replyCount` are computed properties...
thread
=
countRepliesAndDepth
(
thread
,
0
);
return
thread
;
};
describe
(
'Thread'
,
()
=>
{
let
fakeStore
;
let
fakeThreadsService
;
let
fakeThreadUtil
;
// Because this is a recursive component, for most tests, we'll want single,
// flat `thread` object (so we are not misled by rendered children)
const
createComponent
=
props
=>
{
return
mount
(
<
Thread
showDocumentInfo
=
{
false
}
thread
=
{
createThread
()}
threadsService
=
{
fakeThreadsService
}
{...
props
}
/
>
);
};
beforeEach
(()
=>
{
fakeStore
=
{
setCollapsed
:
sinon
.
stub
(),
};
fakeThreadsService
=
{
forceVisible
:
sinon
.
stub
(),
};
fakeThreadUtil
=
{
countHidden
:
sinon
.
stub
(),
countVisible
:
sinon
.
stub
(),
};
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
({
'../store/use-store'
:
callback
=>
callback
(
fakeStore
),
'../util/thread'
:
fakeThreadUtil
,
});
});
afterEach
(()
=>
{
$imports
.
$restore
();
});
context
(
'thread not at top level (depth > 0)'
,
()
=>
{
// "Reply" here means that the thread has a `depth` of > 0, not that it is
// _strictly_ a reply—true annotation replies (per `util.annotation_metadata`)
// have `references`
let
replyThread
;
// Retrieve the (caret) button for showing and hiding replies
const
getToggleButton
=
wrapper
=>
{
return
wrapper
.
find
(
'Button'
).
filter
(
'.thread__collapse-button'
);
};
beforeEach
(()
=>
{
replyThread
=
createThread
();
replyThread
.
depth
=
1
;
replyThread
.
parent
=
'1'
;
});
it
(
'shows the reply toggle controls'
,
()
=>
{
const
wrapper
=
createComponent
({
thread
:
replyThread
});
assert
.
lengthOf
(
getToggleButton
(
wrapper
),
1
);
});
it
(
'collapses the thread when reply toggle clicked on expanded thread'
,
()
=>
{
replyThread
.
collapsed
=
false
;
const
wrapper
=
createComponent
({
thread
:
replyThread
});
act
(()
=>
{
getToggleButton
(
wrapper
)
.
props
()
.
onClick
();
});
assert
.
calledOnce
(
fakeStore
.
setCollapsed
);
assert
.
calledWith
(
fakeStore
.
setCollapsed
,
replyThread
.
id
,
true
);
});
it
(
'assigns an appropriate CSS class to the element'
,
()
=>
{
const
wrapper
=
createComponent
({
thread
:
replyThread
});
assert
.
isTrue
(
wrapper
.
find
(
'.thread'
).
hasClass
(
'thread--reply'
));
});
});
context
(
'visible thread with annotation'
,
()
=>
{
it
(
'renders the annotation moderation banner'
,
()
=>
{
// NB: In the default `thread` provided, `visible` is `true` and there
// is an `annotation` object
const
wrapper
=
createComponent
();
assert
.
isTrue
(
wrapper
.
exists
(
'ModerationBanner'
));
});
it
(
'renders the annotation'
,
()
=>
{
const
wrapper
=
createComponent
();
assert
.
isTrue
(
wrapper
.
exists
(
'Annotation'
));
});
});
context
(
'collapsed thread with annotation and children'
,
()
=>
{
let
collapsedThread
;
beforeEach
(()
=>
{
collapsedThread
=
buildThreadWithChildren
();
collapsedThread
.
collapsed
=
true
;
});
it
(
'assigns an appropriate CSS class to the element'
,
()
=>
{
const
wrapper
=
createComponent
({
thread
:
collapsedThread
});
assert
.
isTrue
(
wrapper
.
find
(
'.thread'
).
hasClass
(
'is-collapsed'
));
assert
.
isFalse
(
wrapper
.
find
(
'.thread__collapse-button'
).
exists
());
});
it
(
'renders reply toggle controls when thread has a parent'
,
()
=>
{
collapsedThread
.
parent
=
'1'
;
const
wrapper
=
createComponent
({
thread
:
collapsedThread
});
assert
.
isTrue
(
wrapper
.
find
(
'.thread__collapse-button'
).
exists
());
});
it
(
'does not render child threads'
,
()
=>
{
const
wrapper
=
createComponent
({
thread
:
collapsedThread
});
assert
.
isFalse
(
wrapper
.
find
(
'.thread__children'
).
exists
());
});
});
context
(
'thread annotation has been deleted'
,
()
=>
{
let
noAnnotationThread
;
beforeEach
(()
=>
{
noAnnotationThread
=
createThread
();
noAnnotationThread
.
annotation
=
undefined
;
});
it
(
'does not render an annotation or a moderation banner'
,
()
=>
{
const
wrapper
=
createComponent
({
thread
:
noAnnotationThread
});
assert
.
isFalse
(
wrapper
.
find
(
'Annotation'
).
exists
());
assert
.
isFalse
(
wrapper
.
find
(
'ModerationBanner'
).
exists
());
});
it
(
'renders an unavailable message'
,
()
=>
{
const
wrapper
=
createComponent
({
thread
:
noAnnotationThread
});
assert
.
isTrue
(
wrapper
.
find
(
'.thread__unavailable-message'
).
exists
());
});
});
context
(
'one or more threads hidden by applied search filter'
,
()
=>
{
beforeEach
(()
=>
{
fakeThreadUtil
.
countHidden
.
returns
(
1
);
});
it
(
'forces the hidden threads visible when show-hidden button clicked'
,
()
=>
{
const
thread
=
createThread
();
const
wrapper
=
createComponent
({
thread
});
act
(()
=>
{
wrapper
.
find
(
'Button'
)
.
filter
({
buttonText
:
'Show 1 more in conversation'
})
.
props
()
.
onClick
();
});
assert
.
calledOnce
(
fakeThreadsService
.
forceVisible
);
assert
.
calledWith
(
fakeThreadsService
.
forceVisible
,
thread
);
});
});
context
(
'thread with child threads'
,
()
=>
{
let
threadWithChildren
;
beforeEach
(()
=>
{
// A child must have at least one visible item to be rendered
fakeThreadUtil
.
countVisible
.
returns
(
1
);
threadWithChildren
=
buildThreadWithChildren
();
});
it
(
'renders child threads'
,
()
=>
{
const
wrapper
=
createComponent
({
thread
:
threadWithChildren
});
assert
.
equal
(
wrapper
.
find
(
'.thread__children'
).
find
(
'Thread'
).
length
,
threadWithChildren
.
replyCount
);
});
it
(
'renders only those children with at least one visible item'
,
()
=>
{
// This has the effect of making the thread's first child _and_ all of
// that child threads descendents not render.
fakeThreadUtil
.
countVisible
.
onFirstCall
().
returns
(
0
);
const
wrapper
=
createComponent
({
thread
:
threadWithChildren
});
// The number of children that end up getting rendered is equal to
// all of the second child's replies plus the second child itself.
assert
.
equal
(
wrapper
.
find
(
'.thread__children'
).
find
(
'Thread'
).
length
,
threadWithChildren
.
children
[
1
].
replyCount
+
1
);
});
});
describe
(
'a11y'
,
()
=>
{
let
threadWithChildren
;
beforeEach
(()
=>
{
threadWithChildren
=
buildThreadWithChildren
();
});
it
(
'should pass a11y checks'
,
checkAccessibility
({
content
:
()
=>
createComponent
({
thread
:
threadWithChildren
}),
})
);
});
});
src/sidebar/components/thread.js
0 → 100644
View file @
a965d91e
import
classnames
from
'classnames'
;
import
{
createElement
,
Fragment
}
from
'preact'
;
import
propTypes
from
'prop-types'
;
import
useStore
from
'../store/use-store'
;
import
{
withServices
}
from
'../util/service-context'
;
import
{
countHidden
,
countVisible
}
from
'../util/thread'
;
import
Annotation
from
'./annotation'
;
import
Button
from
'./button'
;
import
ModerationBanner
from
'./moderation-banner'
;
/**
* A thread, which contains a single annotation at its top level, and its
* recursively-rendered children (i.e. replies). A thread may have a parent,
* and at any given time it may be `collapsed`.
*/
function
Thread
({
showDocumentInfo
=
false
,
thread
,
threadsService
})
{
const
setCollapsed
=
useStore
(
store
=>
store
.
setCollapsed
);
// Only render this thread's annotation if it exists and the thread is `visible`
const
showAnnotation
=
thread
.
annotation
&&
thread
.
visible
;
// Render this thread's replies only if the thread is expanded
const
showChildren
=
!
thread
.
collapsed
;
// Applied search filters will "hide" non-matching threads. If there are
// hidden items within this thread, provide a control to un-hide them.
const
showHiddenToggle
=
countHidden
(
thread
)
>
0
;
// Render a control to expand/collapse the current thread if this thread has
// a parent (i.e. is a reply thread)
const
showThreadToggle
=
!!
thread
.
parent
;
const
toggleIcon
=
thread
.
collapsed
?
'caret-right'
:
'expand-menu'
;
const
toggleTitle
=
thread
.
collapsed
?
'Expand replies'
:
'Collapse replies'
;
// If rendering child threads, only render those that have at least one
// visible item within them—i.e. don't render empty/totally-hidden threads.
const
visibleChildren
=
thread
.
children
.
filter
(
child
=>
countVisible
(
child
)
>
0
);
const
onToggleReplies
=
()
=>
setCollapsed
(
thread
.
id
,
!
thread
.
collapsed
);
return
(
<
div
className
=
{
classnames
(
'thread'
,
{
'thread--reply'
:
thread
.
depth
>
0
,
'is-collapsed'
:
thread
.
collapsed
,
})}
>
{
showThreadToggle
&&
(
<
div
className
=
"thread__collapse"
>
<
Button
className
=
"thread__collapse-button"
icon
=
{
toggleIcon
}
title
=
{
toggleTitle
}
onClick
=
{
onToggleReplies
}
/
>
<
/div
>
)}
<
div
className
=
"thread__content"
>
{
showAnnotation
&&
(
<
Fragment
>
<
ModerationBanner
annotation
=
{
thread
.
annotation
}
/
>
<
Annotation
annotation
=
{
thread
.
annotation
}
replyCount
=
{
thread
.
replyCount
}
onReplyCountClick
=
{
onToggleReplies
}
showDocumentInfo
=
{
showDocumentInfo
}
threadIsCollapsed
=
{
thread
.
collapsed
}
/
>
<
/Fragment
>
)}
{
!
thread
.
annotation
&&
(
<
div
className
=
"thread__unavailable-message"
>
<
em
>
Message
not
available
.
<
/em
>
<
/div
>
)}
{
showHiddenToggle
&&
(
<
Button
buttonText
=
{
`Show
${
countHidden
(
thread
)}
more in conversation`
}
onClick
=
{()
=>
threadsService
.
forceVisible
(
thread
)}
/
>
)}
{
showChildren
&&
(
<
ul
className
=
"thread__children"
>
{
visibleChildren
.
map
(
child
=>
(
<
li
key
=
{
child
.
id
}
>
<
Thread
thread
=
{
child
}
threadsService
=
{
threadsService
}
/
>
<
/li
>
))}
<
/ul
>
)}
<
/div
>
<
/div
>
);
}
Thread
.
propTypes
=
{
showDocumentInfo
:
propTypes
.
bool
,
thread
:
propTypes
.
object
.
isRequired
,
// Injected
threadsService
:
propTypes
.
object
.
isRequired
,
};
Thread
.
injectedProps
=
[
'threadsService'
];
export
default
withServices
(
Thread
);
src/sidebar/index.js
View file @
a965d91e
...
...
@@ -120,12 +120,12 @@ import SelectionTabs from './components/selection-tabs';
import
ShareAnnotationsPanel
from
'./components/share-annotations-panel'
;
import
SidebarContentError
from
'./components/sidebar-content-error'
;
import
SvgIcon
from
'./components/svg-icon'
;
import
Thread
from
'./components/thread'
;
import
ToastMessages
from
'./components/toast-messages'
;
import
TopBar
from
'./components/top-bar'
;
// Remaining UI components that are still built with Angular.
import
annotationThread
from
'./components/annotation-thread'
;
import
annotationViewerContent
from
'./components/annotation-viewer-content'
;
import
hypothesisApp
from
'./components/hypothesis-app'
;
import
sidebarContent
from
'./components/sidebar-content'
;
...
...
@@ -158,6 +158,7 @@ import sessionService from './services/session';
import
streamFilterService
from
'./services/stream-filter'
;
import
streamerService
from
'./services/streamer'
;
import
tagsService
from
'./services/tags'
;
import
threadsService
from
'./services/threads'
;
import
toastMessenger
from
'./services/toast-messenger'
;
import
unicodeService
from
'./services/unicode'
;
import
viewFilterService
from
'./services/view-filter'
;
...
...
@@ -202,6 +203,7 @@ function startAngularApp(config) {
.
register
(
'streamer'
,
streamerService
)
.
register
(
'streamFilter'
,
streamFilterService
)
.
register
(
'tags'
,
tagsService
)
.
register
(
'threadsService'
,
threadsService
)
.
register
(
'toastMessenger'
,
toastMessenger
)
.
register
(
'unicode'
,
unicodeService
)
.
register
(
'viewFilter'
,
viewFilterService
)
...
...
@@ -236,7 +238,6 @@ function startAngularApp(config) {
// UI components
.
component
(
'annotation'
,
wrapComponent
(
Annotation
))
.
component
(
'annotationThread'
,
annotationThread
)
.
component
(
'annotationViewerContent'
,
annotationViewerContent
)
.
component
(
'helpPanel'
,
wrapComponent
(
HelpPanel
))
.
component
(
'loginPromptPanel'
,
wrapComponent
(
LoginPromptPanel
))
...
...
@@ -250,6 +251,7 @@ function startAngularApp(config) {
.
component
(
'shareAnnotationsPanel'
,
wrapComponent
(
ShareAnnotationsPanel
))
.
component
(
'streamContent'
,
streamContent
)
.
component
(
'svgIcon'
,
wrapComponent
(
SvgIcon
))
.
component
(
'thread'
,
wrapComponent
(
Thread
))
.
component
(
'threadList'
,
threadList
)
.
component
(
'toastMessages'
,
wrapComponent
(
ToastMessages
))
.
component
(
'topBar'
,
wrapComponent
(
TopBar
))
...
...
@@ -278,6 +280,7 @@ function startAngularApp(config) {
.
service
(
'session'
,
()
=>
container
.
get
(
'session'
))
.
service
(
'streamer'
,
()
=>
container
.
get
(
'streamer'
))
.
service
(
'streamFilter'
,
()
=>
container
.
get
(
'streamFilter'
))
.
service
(
'threadsService'
,
()
=>
container
.
get
(
'threadsService'
))
.
service
(
'toastMessenger'
,
()
=>
container
.
get
(
'toastMessenger'
))
// Redux store
...
...
src/sidebar/services/test/threads-test.js
0 → 100644
View file @
a965d91e
import
threadsService
from
'../threads'
;
const
NESTED_THREADS
=
{
id
:
'top'
,
children
:
[
{
id
:
'1'
,
children
:
[
{
id
:
'1a'
,
children
:
[{
id
:
'1ai'
,
children
:
[]
}]
},
{
id
:
'1b'
,
children
:
[]
},
{
id
:
'1c'
,
children
:
[{
id
:
'1ci'
,
children
:
[]
}],
},
],
},
{
id
:
'2'
,
children
:
[
{
id
:
'2a'
,
children
:
[]
},
{
id
:
'2b'
,
children
:
[
{
id
:
'2bi'
,
children
:
[]
},
{
id
:
'2bii'
,
children
:
[]
},
],
},
],
},
{
id
:
'3'
,
children
:
[],
},
],
};
describe
(
'threadsService'
,
function
()
{
let
fakeStore
;
let
service
;
beforeEach
(()
=>
{
fakeStore
=
{
setForceVisible
:
sinon
.
stub
(),
};
service
=
threadsService
(
fakeStore
);
});
describe
(
'#forceVisible'
,
()
=>
{
it
(
'should set the thread and its children force-visible in the store'
,
()
=>
{
service
.
forceVisible
(
NESTED_THREADS
);
[
'top'
,
'1'
,
'2'
,
'3'
,
'1a'
,
'1b'
,
'1c'
,
'2a'
,
'2b'
,
'1ai'
,
'1ci'
,
'2bi'
,
'2bii'
,
].
forEach
(
threadId
=>
assert
.
calledWith
(
fakeStore
.
setForceVisible
,
threadId
)
);
});
it
(
'should not set the visibility on thread ancestors'
,
()
=>
{
// This starts at the level with `id` of '1'
service
.
forceVisible
(
NESTED_THREADS
.
children
[
0
]);
const
calledWithThreadIds
=
[];
for
(
let
i
=
0
;
i
<
fakeStore
.
setForceVisible
.
callCount
;
i
++
)
{
calledWithThreadIds
.
push
(
fakeStore
.
setForceVisible
.
getCall
(
i
).
args
[
0
]);
}
assert
.
deepEqual
(
calledWithThreadIds
,
[
'1ai'
,
'1a'
,
'1b'
,
'1ci'
,
'1c'
,
'1'
,
]);
});
});
});
src/sidebar/services/threads.js
0 → 100644
View file @
a965d91e
// @ngInject
export
default
function
threadsService
(
store
)
{
/**
* Make this thread and all of its children "visible". This has the effect of
* "unhiding" a thread which is currently hidden by an applied search filter
* (as well as its child threads).
*/
function
forceVisible
(
thread
)
{
thread
.
children
.
forEach
(
child
=>
{
forceVisible
(
child
);
});
store
.
setForceVisible
(
thread
.
id
,
true
);
}
return
{
forceVisible
,
};
}
src/sidebar/templates/annotation-thread.html
deleted
100644 → 0
View file @
4db9f2a3
<div
ng-class=
"vm.threadClasses()"
>
<div
class=
"annotation-thread__thread-edge"
ng-if=
"!vm.isTopLevelThread()"
>
<a
href=
""
ng-class=
"vm.threadToggleClasses()"
title=
"{{vm.thread.collapsed && 'Expand' || 'Collapse'}}"
ng-click=
"vm.toggleCollapsed()"
>
<svg-icon
name=
"'caret-right'"
ng-if=
"vm.thread.collapsed"
></svg-icon>
<svg-icon
name=
"'expand-menu'"
ng-if=
"!vm.thread.collapsed"
></svg-icon>
</a>
<div
class=
"annotation-thread__thread-line"
></div>
</div>
<div
class=
"annotation-thread__content"
>
<moderation-banner
annotation=
"vm.thread.annotation"
ng-if=
"vm.thread.annotation"
>
</moderation-banner>
<annotation
ng-if=
"vm.thread.annotation && vm.thread.visible"
annotation=
"vm.thread.annotation"
reply-count=
"vm.thread.replyCount"
on-reply-count-click=
"vm.toggleCollapsed()"
show-document-info=
"vm.showDocumentInfo"
thread-is-collapsed=
"vm.thread.collapsed"
>
</annotation>
<div
ng-if=
"!vm.thread.annotation"
class=
"thread-deleted"
>
<p><em>
Message not available.
</em></p>
</div>
<div
ng-if=
"vm.hiddenCount() > 0"
>
<a
class=
"small"
href=
""
ng-click=
"vm.showThreadAndReplies()"
ng-pluralize
count=
"vm.hiddenCount()"
when=
"{'0': '',
one: 'View one more in conversation',
other: 'View {} more in conversation'}"
></a>
</div>
<!-- Replies -->
<ul
ng-show=
"!vm.thread.collapsed"
>
<li
ng-repeat=
"child in vm.thread.children track by child.id"
ng-show=
"vm.shouldShowReply(child)"
>
<annotation-thread
show-document-info=
"false"
thread=
"child"
on-change-collapsed=
"vm.onChangeCollapsed({id:id, collapsed:collapsed})"
on-force-visible=
"vm.onForceVisible(thread)"
>
</annotation-thread>
</li>
</ul>
</div>
</div>
src/sidebar/templates/thread-list.html
View file @
a965d91e
...
...
@@ -8,11 +8,7 @@
ng-class=
"{'thread-list__card--theme-clean' : vm.isThemeClean }"
ng-click=
"vm.onSelect({annotation: child.annotation})"
ng-mouseleave=
"vm.onFocus({annotation: null})"
>
<annotation-thread
thread=
"child"
show-document-info=
"vm.showDocumentInfo"
on-change-collapsed=
"vm.onChangeCollapsed({id: id, collapsed: collapsed})"
>
</annotation-thread>
<thread
thread=
"child"
show-document-info=
"vm.showDocumentInfo"
></thread>
</div>
<hr
ng-if=
"vm.isThemeClean"
class=
"thread-list__separator--theme-clean"
/>
...
...
src/styles/sidebar/components/annotation-header.scss
View file @
a965d91e
...
...
@@ -10,6 +10,7 @@
&
__row
{
display
:
flex
;
flex-wrap
:
wrap-reverse
;
align-items
:
baseline
;
}
...
...
src/styles/sidebar/components/annotation-thread.scss
deleted
100644 → 0
View file @
4db9f2a3
@use
"../../variables"
as
var
;
.annotation-thread
{
display
:
flex
;
flex-direction
:
row
;
}
// Direct or nested reply to an annotation
.annotation-thread--reply
{
// Left margin is set so that left edge of collapse toggle arrow
// for the reply is aligned with the left edge of the parent annotation's
// content.
margin-left
:
-5px
;
}
// Top-level reply to an annotation
.annotation-thread--top-reply
{
padding-top
:
5px
;
padding-bottom
:
5px
;
}
li
:first-child
.annotation-thread--top-reply
{
// Gap between baseline of 'Hide/Show Replies' for annotation and top
// of first reply should be ~15px
margin-top
:
5px
;
}
// Container for the toggle arrow and dashed line at the left edge of replies.
.annotation-thread__thread-edge
{
display
:
flex
;
flex-direction
:
column
;
width
:
8px
;
margin-right
:
13px
;
}
// The dashed line at the left edge of replies
.annotation-thread__thread-line
{
border-right
:
1px
dashed
var
.
$grey-3
;
flex-grow
:
1
;
}
.annotation-thread__content
{
flex-grow
:
1
;
// Prevent annotation content from overflowing the container
max-width
:
100%
;
}
// Darken expand/collapse toggle when an annotation is hovered. This is only
// when the annotation itself is hovered, not the replies.
.annotation-thread__collapse-toggle
:hover
,
.annotation-thread__collapse-toggle.is-hovered
{
color
:
var
.
$grey-7
;
}
// Toggle arrow which expands and collapses threads.
// This is aligned so that it appears above a dashed line which appears
// to the left of the threads.
.annotation-thread__collapse-toggle
{
width
:
10px
;
color
:
var
.
$grey-4
;
display
:
block
;
text-align
:
center
;
margin-left
:
3px
;
font-size
:
15px
;
line-height
:
22px
;
height
:
100%
;
&
.is-open
{
// When the thread is expanded, the top of the dashed line is should be
// aligned with the top of the privacy indicator ("Only me") if present
height
:
24px
;
}
}
src/styles/sidebar/components/thread.scss
0 → 100644
View file @
a965d91e
@use
"../../variables"
as
var
;
.thread
{
display
:
flex
;
&
--reply
{
margin-top
:
0
.5em
;
padding-top
:
0
.5em
;
}
&
__collapse
{
margin
:
0
.25em
;
margin-top
:
0
;
cursor
:
auto
;
border-left
:
1px
dashed
var
.
$grey-3
;
&
:hover
{
border-left
:
1px
dashed
var
.
$grey-4
;
}
.is-collapsed
&
{
border-left
:
none
;
}
}
// TODO These styles should be consolidated with other `Button` styles
&
__collapse-button
{
margin-left
:
-1
.25em
;
padding
:
0
.25em
0
.75em
1em
0
.75em
;
// Need a non-transparent background so that the dashed border line
// does not show through the button
background-color
:
var
.
$white
;
.button__icon
{
width
:
12px
;
height
:
12px
;
color
:
var
.
$grey-4
;
}
&
:hover
{
.button__icon
{
color
:
var
.
$grey-6
;
}
}
}
&
__content
{
flex-grow
:
1
;
// Prevent annotation content from overflowing the container
max-width
:
100%
;
}
}
src/styles/sidebar/sidebar.scss
View file @
a965d91e
...
...
@@ -34,7 +34,6 @@
@use
'./components/annotation-quote'
;
@use
'./components/annotation-share-control'
;
@use
'./components/annotation-share-info'
;
@use
'./components/annotation-thread'
;
@use
'./components/annotation-user'
;
@use
'./components/autocomplete-list'
;
@use
'./components/button'
;
...
...
@@ -62,6 +61,7 @@
@use
'./components/spinner'
;
@use
'./components/tag-editor'
;
@use
'./components/tag-list'
;
@use
'./components/thread'
;
@use
'./components/thread-list'
;
@use
'./components/toast-messages'
;
@use
'./components/tooltip'
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment