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
0374e506
Commit
0374e506
authored
Apr 20, 2020
by
Lyza Danger Gardner
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add interim `ThreadListOmega` component
parent
37e9b424
Changes
3
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
470 additions
and
0 deletions
+470
-0
thread-list-omega-test.js
src/sidebar/components/test/thread-list-omega-test.js
+284
-0
thread-list-omega.js
src/sidebar/components/thread-list-omega.js
+184
-0
index.js
src/sidebar/index.js
+2
-0
No files found.
src/sidebar/components/test/thread-list-omega-test.js
0 → 100644
View file @
0374e506
import
{
mount
}
from
'enzyme'
;
import
{
createElement
}
from
'preact'
;
import
events
from
'../../events'
;
import
{
act
}
from
'preact/test-utils'
;
import
ThreadList
from
'../thread-list-omega'
;
import
{
$imports
}
from
'../thread-list-omega'
;
import
{
checkAccessibility
}
from
'../../../test-util/accessibility'
;
import
mockImportedComponents
from
'../../../test-util/mock-imported-components'
;
describe
(
'ThreadListOmega'
,
()
=>
{
let
fakeDebounce
;
let
fakeDomUtil
;
let
fakeMetadata
;
let
fakeTopThread
;
let
fakeRootScope
;
let
fakeScrollContainer
;
let
fakeStore
;
let
fakeVisibleThreadsUtil
;
function
createComponent
(
props
)
{
return
mount
(
<
ThreadList
thread
=
{
fakeTopThread
}
$rootScope
=
{
fakeRootScope
}
{...
props
}
/>
,
{
attachTo
:
fakeScrollContainer
}
);
}
beforeEach
(()
=>
{
fakeDebounce
=
sinon
.
stub
().
returns
(()
=>
null
);
fakeDomUtil
=
{
getElementHeightWithMargins
:
sinon
.
stub
().
returns
(
0
),
};
fakeMetadata
=
{
isHighlight
:
sinon
.
stub
().
returns
(
false
),
isReply
:
sinon
.
stub
().
returns
(
false
),
};
fakeRootScope
=
{
eventCallbacks
:
{},
$apply
:
function
(
callback
)
{
callback
();
},
$on
:
function
(
event
,
callback
)
{
if
(
event
===
events
.
BEFORE_ANNOTATION_CREATED
)
{
this
.
eventCallbacks
[
event
]
=
callback
;
}
},
$broadcast
:
sinon
.
stub
(),
};
fakeScrollContainer
=
document
.
createElement
(
'div'
);
fakeScrollContainer
.
className
=
'js-thread-list-scroll-root'
;
fakeScrollContainer
.
style
.
height
=
'2000px'
;
document
.
body
.
appendChild
(
fakeScrollContainer
);
fakeStore
=
{
clearSelection
:
sinon
.
stub
(),
};
fakeTopThread
=
{
id
:
't0'
,
annotation
:
{
$tag
:
'myTag0'
},
children
:
[
{
id
:
't1'
,
children
:
[],
annotation
:
{
$tag
:
't1'
}
},
{
id
:
't2'
,
children
:
[],
annotation
:
{
$tag
:
't2'
}
},
{
id
:
't3'
,
children
:
[],
annotation
:
{
$tag
:
't3'
}
},
{
id
:
't4'
,
children
:
[],
annotation
:
{
$tag
:
't4'
}
},
],
};
fakeVisibleThreadsUtil
=
{
calculateVisibleThreads
:
sinon
.
stub
().
returns
({
visibleThreads
:
fakeTopThread
.
children
,
offscreenUpperHeight
:
400
,
offscreenLowerHeight
:
600
,
}),
THREAD_DIMENSION_DEFAULTS
:
{
defaultHeight
:
200
,
},
};
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
({
'lodash.debounce'
:
fakeDebounce
,
'../store/use-store'
:
callback
=>
callback
(
fakeStore
),
'../util/annotation-metadata'
:
fakeMetadata
,
'../util/dom'
:
fakeDomUtil
,
'../util/visible-threads'
:
fakeVisibleThreadsUtil
,
});
});
afterEach
(()
=>
{
$imports
.
$restore
();
fakeScrollContainer
.
remove
();
});
it
(
'works'
,
()
=>
{
const
wrapper
=
createComponent
();
assert
.
isTrue
(
wrapper
.
find
(
'section'
).
exists
());
});
it
(
'calculates visible threads'
,
()
=>
{
createComponent
();
assert
.
calledWith
(
fakeVisibleThreadsUtil
.
calculateVisibleThreads
,
fakeTopThread
.
children
,
sinon
.
match
({}),
0
,
sinon
.
match
.
number
);
});
context
(
'new annotation created in application'
,
()
=>
{
it
(
'attaches a listener for the BEFORE_ANNOTATION_CREATED event'
,
()
=>
{
fakeRootScope
.
$on
=
sinon
.
stub
();
createComponent
();
assert
.
calledWith
(
fakeRootScope
.
$on
,
events
.
BEFORE_ANNOTATION_CREATED
,
sinon
.
match
.
func
);
});
it
(
'clears the current selection in the store'
,
()
=>
{
createComponent
();
fakeRootScope
.
eventCallbacks
[
events
.
BEFORE_ANNOTATION_CREATED
]({},
{});
assert
.
calledOnce
(
fakeStore
.
clearSelection
);
});
it
(
'does not clear the selection in the store if new annotation is a highlight'
,
()
=>
{
fakeMetadata
.
isHighlight
.
returns
(
true
);
createComponent
();
fakeRootScope
.
eventCallbacks
[
events
.
BEFORE_ANNOTATION_CREATED
]({},
{});
assert
.
notCalled
(
fakeStore
.
clearSelection
);
});
it
(
'does not clear the selection in the store if new annotation is a reply'
,
()
=>
{
fakeMetadata
.
isReply
.
returns
(
true
);
createComponent
();
fakeRootScope
.
eventCallbacks
[
events
.
BEFORE_ANNOTATION_CREATED
]({},
{});
assert
.
notCalled
(
fakeStore
.
clearSelection
);
});
});
context
(
'active scroll to an annotation thread'
,
()
=>
{
let
stubbedDocument
;
let
stubbedFakeScrollContainer
;
let
fakeScrollTop
;
beforeEach
(()
=>
{
fakeScrollTop
=
sinon
.
stub
();
stubbedFakeScrollContainer
=
sinon
.
stub
(
fakeScrollContainer
,
'scrollTop'
)
.
set
(
fakeScrollTop
);
stubbedDocument
=
sinon
.
stub
(
document
,
'querySelector'
)
.
returns
(
fakeScrollContainer
);
});
afterEach
(()
=>
{
stubbedFakeScrollContainer
.
restore
();
stubbedDocument
.
restore
();
});
it
(
'should do nothing if there is no active annotation thread to scroll to'
,
()
=>
{
createComponent
();
assert
.
notCalled
(
fakeScrollTop
);
});
it
(
'should do nothing if the annotation thread to scroll to is not in DOM'
,
()
=>
{
createComponent
();
act
(()
=>
{
fakeRootScope
.
eventCallbacks
[
events
.
BEFORE_ANNOTATION_CREATED
](
{},
{
$tag
:
'nonexistent'
}
);
});
assert
.
notCalled
(
fakeScrollTop
);
});
it
(
'should set the scroll container `scrollTop` to derived position of thread'
,
()
=>
{
createComponent
();
act
(()
=>
{
fakeRootScope
.
eventCallbacks
[
events
.
BEFORE_ANNOTATION_CREATED
](
{},
fakeTopThread
.
children
[
3
].
annotation
);
});
// The third thread in a collection of threads at default height (200)
// should be at 600px. This setting of `scrollTop` is the only externally-
// observable thing that happens here...
assert
.
calledWith
(
fakeScrollTop
,
600
);
});
});
describe
(
'scroll and resize event handling'
,
()
=>
{
let
debouncedFn
;
beforeEach
(()
=>
{
debouncedFn
=
sinon
.
stub
();
fakeDebounce
.
returns
(
debouncedFn
);
});
it
(
'updates scroll position and window height for recalculation on container scroll'
,
()
=>
{
createComponent
();
document
.
querySelector
(
'.js-thread-list-scroll-root'
)
.
dispatchEvent
(
new
Event
(
'scroll'
));
assert
.
calledOnce
(
debouncedFn
);
});
it
(
'updates scroll position and window height for recalculation on window resize'
,
()
=>
{
createComponent
();
window
.
dispatchEvent
(
new
Event
(
'resize'
));
assert
.
calledOnce
(
debouncedFn
);
});
});
context
(
'when top-level threads change'
,
()
=>
{
it
(
'recalculates thread heights'
,
()
=>
{
const
wrapper
=
createComponent
();
// Initial render and effect hooks will trigger calculation twice
fakeDomUtil
.
getElementHeightWithMargins
.
resetHistory
();
// Let's see it respond to the top-level threads changing
wrapper
.
setProps
({
thread
:
fakeTopThread
});
// It should check the element height for each top-level thread (assuming
// they are all onscreen, which these test threads "are")
assert
.
equal
(
fakeDomUtil
.
getElementHeightWithMargins
.
callCount
,
fakeTopThread
.
children
.
length
);
});
});
it
(
'renders dimensional elements above and below visible threads'
,
()
=>
{
const
wrapper
=
createComponent
();
const
upperDiv
=
wrapper
.
find
(
'div'
).
first
();
const
lowerDiv
=
wrapper
.
find
(
'div'
).
last
();
assert
.
equal
(
upperDiv
.
getDOMNode
().
style
.
height
,
'400px'
);
assert
.
equal
(
lowerDiv
.
getDOMNode
().
style
.
height
,
'600px'
);
});
it
(
'renders a `ThreadCard` for each visible thread'
,
()
=>
{
const
wrapper
=
createComponent
();
const
cards
=
wrapper
.
find
(
'ThreadCard'
);
assert
.
equal
(
cards
.
length
,
fakeTopThread
.
children
.
length
);
});
it
(
'should pass a11y checks'
,
checkAccessibility
({
content
:
()
=>
{
const
wrapper
=
createComponent
();
return
wrapper
;
},
})
);
});
src/sidebar/components/thread-list-omega.js
0 → 100644
View file @
0374e506
import
{
createElement
}
from
'preact'
;
import
{
useEffect
,
useState
}
from
'preact/hooks'
;
import
propTypes
from
'prop-types'
;
import
debounce
from
'lodash.debounce'
;
import
shallowEqual
from
'shallowequal'
;
import
events
from
'../events'
;
import
useStore
from
'../store/use-store'
;
import
{
isHighlight
,
isReply
}
from
'../util/annotation-metadata'
;
import
{
getElementHeightWithMargins
}
from
'../util/dom'
;
import
{
withServices
}
from
'../util/service-context'
;
import
{
calculateVisibleThreads
,
THREAD_DIMENSION_DEFAULTS
,
}
from
'../util/visible-threads'
;
import
ThreadCard
from
'./thread-card'
;
function
getScrollContainer
()
{
return
document
.
querySelector
(
'.js-thread-list-scroll-root'
);
}
/**
* Render a list of threads, but only render those that are in or near the
* current browser viewport.
*/
function
ThreadListOmega
({
thread
,
$rootScope
})
{
const
clearSelection
=
useStore
(
store
=>
store
.
clearSelection
);
// Height of the scrollable container. For the moment, this is the same as
// the window height.
const
[
windowHeight
,
setWindowHeight
]
=
useState
(
window
.
innerHeight
);
// Scroll offset of scroll container. This is updated after the scroll
// container is scrolled, with debouncing to limit update frequency.
const
[
scrollPosition
,
setScrollPosition
]
=
useState
(
getScrollContainer
().
scrollTop
);
// Map of thread ID to measured height of thread.
const
[
threadHeights
,
setThreadHeights
]
=
useState
({});
// ID of thread to scroll to after the next render. If the thread is not
// present, the value persists until it can be "consumed".
const
[
scrollToId
,
setScrollToId
]
=
useState
(
null
);
const
topLevelThreads
=
thread
.
children
;
const
topLevelThreadIds
=
topLevelThreads
.
map
(
t
=>
t
.
id
);
const
{
offscreenLowerHeight
,
offscreenUpperHeight
,
visibleThreads
,
}
=
calculateVisibleThreads
(
topLevelThreads
,
threadHeights
,
scrollPosition
,
windowHeight
);
// Listen for when a new annotation is created in the application, and trigger
// a scroll to that annotation's thread card (unless highlight or reply)
useEffect
(()
=>
{
const
removeListener
=
$rootScope
.
$on
(
events
.
BEFORE_ANNOTATION_CREATED
,
(
event
,
annotation
)
=>
{
if
(
!
isHighlight
(
annotation
)
&&
!
isReply
(
annotation
))
{
clearSelection
();
setScrollToId
(
annotation
.
$tag
);
}
}
);
return
removeListener
;
},
[
$rootScope
,
clearSelection
]);
// Effect to scroll a particular thread into view. This is mainly used to
// scroll a newly created annotation into view (as triggered by the
// listener for `BEFORE_ANNOTATION_CREATED`)
useEffect
(()
=>
{
if
(
!
scrollToId
)
{
return
;
}
const
threadIndex
=
topLevelThreads
.
findIndex
(
t
=>
t
.
id
===
scrollToId
);
if
(
threadIndex
===
-
1
)
{
// Thread is not currently present.
//
// When `scrollToId` is set as a result of a `BEFORE_ANNOTATION_CREATED`
// event, the annotation is not always present in the _next_ render after
// that event is received, but in another render after that. Therefore
// we wait until the annotation appears before "consuming" the scroll-to-id.
return
;
}
// Clear `scrollToId` so we don't scroll again after the next render.
setScrollToId
(
null
);
const
getThreadHeight
=
thread
=>
threadHeights
[
thread
.
id
]
||
THREAD_DIMENSION_DEFAULTS
.
defaultHeight
;
const
yOffset
=
topLevelThreads
.
slice
(
0
,
threadIndex
)
.
reduce
((
total
,
thread
)
=>
total
+
getThreadHeight
(
thread
),
0
);
const
scrollContainer
=
getScrollContainer
();
scrollContainer
.
scrollTop
=
yOffset
;
},
[
scrollToId
,
topLevelThreads
,
threadHeights
]);
// Attach listeners such that whenever the scroll container is scrolled or the
// window resized, a recalculation of visible threads is triggered
useEffect
(()
=>
{
const
scrollContainer
=
getScrollContainer
();
const
updateScrollPosition
=
debounce
(
()
=>
{
setWindowHeight
(
window
.
innerHeight
);
setScrollPosition
(
scrollContainer
.
scrollTop
);
},
10
,
{
maxWait
:
100
}
);
scrollContainer
.
addEventListener
(
'scroll'
,
updateScrollPosition
);
window
.
addEventListener
(
'resize'
,
updateScrollPosition
);
return
()
=>
{
scrollContainer
.
removeEventListener
(
'scroll'
,
updateScrollPosition
);
window
.
removeEventListener
(
'resize'
,
updateScrollPosition
);
};
},
[]);
// When the set of top-level threads changes, recalculate the real rendered
// heights of thread cards and update `threadHeights` state if there are changes.
useEffect
(()
=>
{
let
newHeights
=
{};
for
(
let
id
of
topLevelThreadIds
)
{
const
threadElement
=
document
.
getElementById
(
id
);
if
(
!
threadElement
)
{
// Thread is currently off screen.
continue
;
}
const
height
=
getElementHeightWithMargins
(
threadElement
);
if
(
height
!==
null
)
{
newHeights
[
id
]
=
height
;
}
}
// Functional update of `threadHeights` state: `heights` is previous state
setThreadHeights
(
heights
=>
{
// Merge existing and new heights.
newHeights
=
Object
.
assign
({},
heights
,
newHeights
);
// Skip update if no heights actually changed.
if
(
shallowEqual
(
heights
,
newHeights
))
{
return
heights
;
}
return
newHeights
;
});
},
[
topLevelThreadIds
]);
return
(
<
section
>
<
div
style
=
{{
height
:
offscreenUpperHeight
}}
/
>
{
visibleThreads
.
map
(
child
=>
(
<
div
id
=
{
child
.
id
}
key
=
{
child
.
id
}
>
<
ThreadCard
thread
=
{
child
}
/
>
<
/div
>
))}
<
div
style
=
{{
height
:
offscreenLowerHeight
}}
/
>
<
/section
>
);
}
ThreadListOmega
.
propTypes
=
{
/** Should annotations render extra document metadata? */
thread
:
propTypes
.
object
.
isRequired
,
/** injected */
$rootScope
:
propTypes
.
object
.
isRequired
,
};
ThreadListOmega
.
injectedProps
=
[
'$rootScope'
];
export
default
withServices
(
ThreadListOmega
);
src/sidebar/index.js
View file @
0374e506
...
...
@@ -136,6 +136,7 @@ import hypothesisApp from './components/hypothesis-app';
import
sidebarContent
from
'./components/sidebar-content'
;
import
streamContent
from
'./components/stream-content'
;
import
threadList
from
'./components/thread-list'
;
import
threadListOmega
from
'./components/thread-list-omega'
;
// Services.
...
...
@@ -268,6 +269,7 @@ function startAngularApp(config) {
.
component
(
'svgIcon'
,
wrapComponent
(
SvgIcon
))
.
component
(
'thread'
,
wrapComponent
(
Thread
))
.
component
(
'threadList'
,
threadList
)
.
component
(
'threadListOmega'
,
wrapComponent
(
threadListOmega
))
.
component
(
'toastMessages'
,
wrapComponent
(
ToastMessages
))
.
component
(
'topBar'
,
wrapComponent
(
TopBar
))
...
...
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