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
80c9e2e3
Commit
80c9e2e3
authored
Mar 26, 2020
by
Lyza Danger Gardner
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add preact `Thread` component and new `threadsService`
parent
f98d0ebb
Changes
7
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
564 additions
and
0 deletions
+564
-0
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
-0
threads-test.js
src/sidebar/services/test/threads-test.js
+89
-0
threads.js
src/sidebar/services/threads.js
+18
-0
thread.scss
src/styles/sidebar/components/thread.scss
+52
-0
sidebar.scss
src/styles/sidebar/sidebar.scss
+1
-0
No files found.
src/sidebar/components/test/thread-test.js
0 → 100644
View file @
80c9e2e3
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 @
80c9e2e3
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 @
80c9e2e3
...
@@ -120,6 +120,7 @@ import SelectionTabs from './components/selection-tabs';
...
@@ -120,6 +120,7 @@ import SelectionTabs from './components/selection-tabs';
import
ShareAnnotationsPanel
from
'./components/share-annotations-panel'
;
import
ShareAnnotationsPanel
from
'./components/share-annotations-panel'
;
import
SidebarContentError
from
'./components/sidebar-content-error'
;
import
SidebarContentError
from
'./components/sidebar-content-error'
;
import
SvgIcon
from
'./components/svg-icon'
;
import
SvgIcon
from
'./components/svg-icon'
;
import
Thread
from
'./components/thread'
;
import
ToastMessages
from
'./components/toast-messages'
;
import
ToastMessages
from
'./components/toast-messages'
;
import
TopBar
from
'./components/top-bar'
;
import
TopBar
from
'./components/top-bar'
;
...
@@ -158,6 +159,7 @@ import sessionService from './services/session';
...
@@ -158,6 +159,7 @@ import sessionService from './services/session';
import
streamFilterService
from
'./services/stream-filter'
;
import
streamFilterService
from
'./services/stream-filter'
;
import
streamerService
from
'./services/streamer'
;
import
streamerService
from
'./services/streamer'
;
import
tagsService
from
'./services/tags'
;
import
tagsService
from
'./services/tags'
;
import
threadsService
from
'./services/threads'
;
import
toastMessenger
from
'./services/toast-messenger'
;
import
toastMessenger
from
'./services/toast-messenger'
;
import
unicodeService
from
'./services/unicode'
;
import
unicodeService
from
'./services/unicode'
;
import
viewFilterService
from
'./services/view-filter'
;
import
viewFilterService
from
'./services/view-filter'
;
...
@@ -202,6 +204,7 @@ function startAngularApp(config) {
...
@@ -202,6 +204,7 @@ function startAngularApp(config) {
.
register
(
'streamer'
,
streamerService
)
.
register
(
'streamer'
,
streamerService
)
.
register
(
'streamFilter'
,
streamFilterService
)
.
register
(
'streamFilter'
,
streamFilterService
)
.
register
(
'tags'
,
tagsService
)
.
register
(
'tags'
,
tagsService
)
.
register
(
'threadsService'
,
threadsService
)
.
register
(
'toastMessenger'
,
toastMessenger
)
.
register
(
'toastMessenger'
,
toastMessenger
)
.
register
(
'unicode'
,
unicodeService
)
.
register
(
'unicode'
,
unicodeService
)
.
register
(
'viewFilter'
,
viewFilterService
)
.
register
(
'viewFilter'
,
viewFilterService
)
...
@@ -250,6 +253,7 @@ function startAngularApp(config) {
...
@@ -250,6 +253,7 @@ function startAngularApp(config) {
.
component
(
'shareAnnotationsPanel'
,
wrapComponent
(
ShareAnnotationsPanel
))
.
component
(
'shareAnnotationsPanel'
,
wrapComponent
(
ShareAnnotationsPanel
))
.
component
(
'streamContent'
,
streamContent
)
.
component
(
'streamContent'
,
streamContent
)
.
component
(
'svgIcon'
,
wrapComponent
(
SvgIcon
))
.
component
(
'svgIcon'
,
wrapComponent
(
SvgIcon
))
.
component
(
'thread'
,
wrapComponent
(
Thread
))
.
component
(
'threadList'
,
threadList
)
.
component
(
'threadList'
,
threadList
)
.
component
(
'toastMessages'
,
wrapComponent
(
ToastMessages
))
.
component
(
'toastMessages'
,
wrapComponent
(
ToastMessages
))
.
component
(
'topBar'
,
wrapComponent
(
TopBar
))
.
component
(
'topBar'
,
wrapComponent
(
TopBar
))
...
@@ -278,6 +282,7 @@ function startAngularApp(config) {
...
@@ -278,6 +282,7 @@ function startAngularApp(config) {
.
service
(
'session'
,
()
=>
container
.
get
(
'session'
))
.
service
(
'session'
,
()
=>
container
.
get
(
'session'
))
.
service
(
'streamer'
,
()
=>
container
.
get
(
'streamer'
))
.
service
(
'streamer'
,
()
=>
container
.
get
(
'streamer'
))
.
service
(
'streamFilter'
,
()
=>
container
.
get
(
'streamFilter'
))
.
service
(
'streamFilter'
,
()
=>
container
.
get
(
'streamFilter'
))
.
service
(
'threadsService'
,
()
=>
container
.
get
(
'threadsService'
))
.
service
(
'toastMessenger'
,
()
=>
container
.
get
(
'toastMessenger'
))
.
service
(
'toastMessenger'
,
()
=>
container
.
get
(
'toastMessenger'
))
// Redux store
// Redux store
...
...
src/sidebar/services/test/threads-test.js
0 → 100644
View file @
80c9e2e3
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 @
80c9e2e3
// @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/styles/sidebar/components/thread.scss
0 → 100644
View file @
80c9e2e3
@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 @
80c9e2e3
...
@@ -62,6 +62,7 @@
...
@@ -62,6 +62,7 @@
@use
'./components/spinner'
;
@use
'./components/spinner'
;
@use
'./components/tag-editor'
;
@use
'./components/tag-editor'
;
@use
'./components/tag-list'
;
@use
'./components/tag-list'
;
@use
'./components/thread'
;
@use
'./components/thread-list'
;
@use
'./components/thread-list'
;
@use
'./components/toast-messages'
;
@use
'./components/toast-messages'
;
@use
'./components/tooltip'
;
@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