Commit 96081234 authored by Kyle Keating's avatar Kyle Keating Committed by Robert Knight

improve typechecking

- thread-list
- thread-card
- thread
- timestamp
parent 2a96432a
...@@ -27,10 +27,7 @@ export default function AnnotationHeader({ ...@@ -27,10 +27,7 @@ export default function AnnotationHeader({
const isCollapsedReply = isReply(annotation) && threadIsCollapsed; const isCollapsedReply = isReply(annotation) && threadIsCollapsed;
const setExpanded = useStore(store => store.setExpanded); const setExpanded = useStore(store => store.setExpanded);
const annotationIsPrivate = isPrivate( const annotationIsPrivate = isPrivate(annotation.permissions);
annotation.permissions,
annotation.user
);
const annotationLink = annotation.links ? annotation.links.html : ''; const annotationLink = annotation.links ? annotation.links.html : '';
// NB: `created` and `updated` are strings, not `Date`s // NB: `created` and `updated` are strings, not `Date`s
......
...@@ -6,6 +6,10 @@ import { isPrivate } from '../util/permissions'; ...@@ -6,6 +6,10 @@ import { isPrivate } from '../util/permissions';
import SvgIcon from '../../shared/components/svg-icon'; import SvgIcon from '../../shared/components/svg-icon';
/**
* @typedef {import('../../types/api').Group} Group
*/
/** /**
* Render information about what group an annotation is in and * Render information about what group an annotation is in and
* whether it is private to the current user (only me) * whether it is private to the current user (only me)
...@@ -13,21 +17,15 @@ import SvgIcon from '../../shared/components/svg-icon'; ...@@ -13,21 +17,15 @@ import SvgIcon from '../../shared/components/svg-icon';
function AnnotationShareInfo({ annotation }) { function AnnotationShareInfo({ annotation }) {
const group = useStore(store => store.getGroup(annotation.group)); const group = useStore(store => store.getGroup(annotation.group));
// We may not have access to the group object beyond its ID
const hasGroup = !!group;
// Only show the name of the group and link to it if there is a // Only show the name of the group and link to it if there is a
// URL (link) returned by the API for this group. Some groups do not have links // URL (link) returned by the API for this group. Some groups do not have links
const linkToGroup = hasGroup && group.links && group.links.html; const linkToGroup = group?.links.html;
const annotationIsPrivate = isPrivate( const annotationIsPrivate = isPrivate(annotation.permissions);
annotation.permissions,
annotation.user
);
return ( return (
<div className="annotation-share-info u-layout-row--align-baseline"> <div className="annotation-share-info u-layout-row--align-baseline">
{linkToGroup && ( {group && linkToGroup && (
<a <a
className="u-layout-row--align-baseline u-color-text--muted" className="u-layout-row--align-baseline u-color-text--muted"
href={group.links.html} href={group.links.html}
......
...@@ -53,7 +53,8 @@ function Annotation({ ...@@ -53,7 +53,8 @@ function Annotation({
const toggleText = `${toggleAction} (${replyCount})`; const toggleText = `${toggleAction} (${replyCount})`;
const shouldShowActions = !isSaving && !isEditing; const shouldShowActions = !isSaving && !isEditing;
const shouldShowLicense = isEditing && !isPrivate && group.type !== 'private'; const shouldShowLicense =
isEditing && !isPrivate && group && group.type !== 'private';
const shouldShowReplyToggle = replyCount > 0 && !isReply(annotation); const shouldShowReplyToggle = replyCount > 0 && !isReply(annotation);
const onEditTags = ({ tags }) => { const onEditTags = ({ tags }) => {
......
...@@ -108,7 +108,7 @@ describe('AnnotationShareInfo', () => { ...@@ -108,7 +108,7 @@ describe('AnnotationShareInfo', () => {
}); });
it('should show "only me" text for annotation in third-party group', () => { it('should show "only me" text for annotation in third-party group', () => {
fakeGetGroup.returns({ name: 'Some Name' }); fakeGetGroup.returns({ name: 'Some Name', links: {} });
const wrapper = createAnnotationShareInfo(); const wrapper = createAnnotationShareInfo();
const privacyText = wrapper.find( const privacyText = wrapper.find(
......
...@@ -103,6 +103,15 @@ describe('ThreadList', () => { ...@@ -103,6 +103,15 @@ describe('ThreadList', () => {
wrapper.setProps({}); wrapper.setProps({});
}; };
context('invalid scroll container', () => {
it('should throw an error if the scroll container is missing', () => {
fakeScrollContainer.remove();
assert.throws(() => {
createComponent();
}, 'Scroll container is missing');
});
});
context('new annotation created in application', () => { context('new annotation created in application', () => {
it('clears the current selection in the store', () => { it('clears the current selection in the store', () => {
const wrapper = createComponent(); const wrapper = createComponent();
......
...@@ -10,11 +10,24 @@ import { withServices } from '../util/service-context'; ...@@ -10,11 +10,24 @@ import { withServices } from '../util/service-context';
import Thread from './thread'; import Thread from './thread';
/**
* @typedef {import('../../types/config').MergedConfig} MergedConfig
*/
/**
* @typedef ThreadCardProps
* @prop {Thread} thread
* @prop {Object} frameSync - Injected service
* @prop {MergedConfig} [settings] - Injected service
*/
/** /**
* A "top-level" `Thread`, rendered as a "card" in the sidebar. A `Thread` * A "top-level" `Thread`, rendered as a "card" in the sidebar. A `Thread`
* renders its own child `Thread`s within itself. * renders its own child `Thread`s within itself.
*
* @param {ThreadCardProps} props
*/ */
function ThreadCard({ frameSync, settings = {}, thread }) { function ThreadCard({ frameSync, settings, thread }) {
const threadTag = thread.annotation && thread.annotation.$tag; const threadTag = thread.annotation && thread.annotation.$tag;
const isFocused = useStore(store => store.isAnnotationFocused(threadTag)); const isFocused = useStore(store => store.isAnnotationFocused(threadTag));
const showDocumentInfo = useStore(store => store.route() !== 'sidebar'); const showDocumentInfo = useStore(store => store.route() !== 'sidebar');
...@@ -67,8 +80,6 @@ function ThreadCard({ frameSync, settings = {}, thread }) { ...@@ -67,8 +80,6 @@ function ThreadCard({ frameSync, settings = {}, thread }) {
ThreadCard.propTypes = { ThreadCard.propTypes = {
thread: propTypes.object.isRequired, thread: propTypes.object.isRequired,
/** injected */
frameSync: propTypes.object.isRequired, frameSync: propTypes.object.isRequired,
settings: propTypes.object, settings: propTypes.object,
}; };
......
...@@ -13,14 +13,25 @@ import { ...@@ -13,14 +13,25 @@ import {
import ThreadCard from './thread-card'; import ThreadCard from './thread-card';
/** @typedef {import('../util/build-thread').Thread} Thread */
// The precision of the `scrollPosition` value in pixels; values will be rounded // The precision of the `scrollPosition` value in pixels; values will be rounded
// down to the nearest multiple of this scale value // down to the nearest multiple of this scale value
const SCROLL_PRECISION = 50; const SCROLL_PRECISION = 50;
function getScrollContainer() { function getScrollContainer() {
return document.querySelector('.js-thread-list-scroll-root'); const container = document.querySelector('.js-thread-list-scroll-root');
if (!container) {
throw new Error('Scroll container is missing');
}
return container;
} }
/**
* @typedef ThreadListProps
* @prop {Thread} thread
*/
/** /**
* Render a list of threads. * Render a list of threads.
* *
...@@ -29,6 +40,8 @@ function getScrollContainer() { ...@@ -29,6 +40,8 @@ function getScrollContainer() {
* annotations (and replies) are complex interactive components whose * annotations (and replies) are complex interactive components whose
* user-defined content may include rich media such as images, audio clips, * user-defined content may include rich media such as images, audio clips,
* embedded YouTube videos, rendered math and more. * embedded YouTube videos, rendered math and more.
*
* @param {ThreadListProps} props
*/ */
function ThreadList({ thread }) { function ThreadList({ thread }) {
const clearSelection = useStore(store => store.clearSelection); const clearSelection = useStore(store => store.clearSelection);
...@@ -51,7 +64,9 @@ function ThreadList({ thread }) { ...@@ -51,7 +64,9 @@ function ThreadList({ thread }) {
// ID of thread to scroll to after the next render. If the thread is not // ID of thread to scroll to after the next render. If the thread is not
// present, the value persists until it can be "consumed". // present, the value persists until it can be "consumed".
const [scrollToId, setScrollToId] = useState(null); const [scrollToId, setScrollToId] = useState(
/** @type {string|null} */ (null)
);
const topLevelThreads = thread.children; const topLevelThreads = thread.children;
...@@ -159,7 +174,9 @@ function ThreadList({ thread }) { ...@@ -159,7 +174,9 @@ function ThreadList({ thread }) {
setThreadHeights(prevHeights => { setThreadHeights(prevHeights => {
const changedHeights = {}; const changedHeights = {};
for (let { id } of visibleThreads) { for (let { id } of visibleThreads) {
const threadElement = document.getElementById(id); const threadElement = /** @type {HTMLElement} */ (document.getElementById(
id
));
const height = getElementHeightWithMargins(threadElement); const height = getElementHeightWithMargins(threadElement);
if (height !== prevHeights[id]) { if (height !== prevHeights[id]) {
changedHeights[id] = height; changedHeights[id] = height;
......
...@@ -10,10 +10,21 @@ import Annotation from './annotation'; ...@@ -10,10 +10,21 @@ import Annotation from './annotation';
import Button from './button'; import Button from './button';
import ModerationBanner from './moderation-banner'; import ModerationBanner from './moderation-banner';
/** @typedef {import('../util/build-thread').Thread} Thread */
/**
* @typedef ThreadProps
* @prop {boolean} [showDocumentInfo]
* @prop {Thread} thread
* @prop {Object} threadsService - Injected service
*/
/** /**
* A thread, which contains a single annotation at its top level, and its * 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, * recursively-rendered children (i.e. replies). A thread may have a parent,
* and at any given time it may be `collapsed`. * and at any given time it may be `collapsed`.
*
* @param {ThreadProps} props
*/ */
function Thread({ showDocumentInfo = false, thread, threadsService }) { function Thread({ showDocumentInfo = false, thread, threadsService }) {
const setExpanded = useStore(store => store.setExpanded); const setExpanded = useStore(store => store.setExpanded);
......
...@@ -5,9 +5,18 @@ import propTypes from 'prop-types'; ...@@ -5,9 +5,18 @@ import propTypes from 'prop-types';
import { format as formatDate } from '../util/date'; import { format as formatDate } from '../util/date';
import { decayingInterval, toFuzzyString } from '../util/time'; import { decayingInterval, toFuzzyString } from '../util/time';
/**
* @typedef TimestampProps
* @prop {string} [className] - Custom class name for the anchor/span element
* @prop {string} [href] - Link destination
* @prop {string} timestamp - The timestamp as an ISO 8601 date string
*/
/** /**
* Display a relative timestamp (eg. '6 minutes ago') as static text or a link. * Display a relative timestamp (eg. '6 minutes ago') as static text or a link.
* *
* @param {TimestampProps} props
*
* The timestamp automatically refreshes at an appropriate frequency. * The timestamp automatically refreshes at an appropriate frequency.
*/ */
export default function Timestamp({ className, href, timestamp }) { export default function Timestamp({ className, href, timestamp }) {
...@@ -49,14 +58,7 @@ export default function Timestamp({ className, href, timestamp }) { ...@@ -49,14 +58,7 @@ export default function Timestamp({ className, href, timestamp }) {
} }
Timestamp.propTypes = { Timestamp.propTypes = {
/** Custom class name for the anchor/span element. */
className: propTypes.string, className: propTypes.string,
/** Link destination. */
href: propTypes.string, href: propTypes.string,
/**
* The timestamp as an ISO 8601 date string.
*/
timestamp: propTypes.string.isRequired, timestamp: propTypes.string.isRequired,
}; };
...@@ -564,6 +564,7 @@ function savedAnnotations(state) { ...@@ -564,6 +564,7 @@ function savedAnnotations(state) {
* @prop {(tags: string[]) => string[]} findIDsForTags * @prop {(tags: string[]) => string[]} findIDsForTags
* @prop {() => string[]} focusedAnnotations * @prop {() => string[]} focusedAnnotations
* @prop {() => string[]} highlightedAnnotations * @prop {() => string[]} highlightedAnnotations
* @prop {(tag: string) => boolean} isAnnotationFocused
* @prop {() => boolean} isWaitingToAnchorAnnotations * @prop {() => boolean} isWaitingToAnchorAnnotations
* @prop {() => Annotation[]} newAnnotations * @prop {() => Annotation[]} newAnnotations
* @prop {() => Annotation[]} newHighlights * @prop {() => Annotation[]} newHighlights
......
...@@ -35,9 +35,6 @@ ...@@ -35,9 +35,6 @@
"sidebar/components/sidebar-content-error.js", "sidebar/components/sidebar-content-error.js",
"sidebar/components/sidebar-content.js", "sidebar/components/sidebar-content.js",
"sidebar/components/stream-content.js", "sidebar/components/stream-content.js",
"sidebar/components/thread-card.js",
"sidebar/components/thread-list.js",
"sidebar/components/thread.js",
"sidebar/components/top-bar.js" "sidebar/components/top-bar.js"
] ]
} }
...@@ -116,7 +116,7 @@ ...@@ -116,7 +116,7 @@
* we assign a default organization on the client. * we assign a default organization on the client.
* @prop {GroupScopes|null} scopes * @prop {GroupScopes|null} scopes
* @prop {Object} links * @prop {Object} links
* @prop {string} links.html * @prop {string} [links.html]
* *
* // Properties not present on API objects, but added by utilities in the client. * // Properties not present on API objects, but added by utilities in the client.
* @prop {string} logo * @prop {string} logo
......
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