Commit 711427e6 authored by Robert Knight's avatar Robert Knight

Improve types of annotation values in several places

 - Introduce a `SavedAnnotation` type to represent annotations which
   have definitely been saved and an associated `isSaved` function to
   test. Use this as the type for several methods which used to
   implicitly assume they were passed a saved annotation.

   Calls to `isSaved` have also been added in several places to check
   that an annotation has been saved, although this should always have
   been the case in practice.

 - Add missing annotation types to various parameters in
   `AnnotationsService`
parent 5847ee5c
...@@ -2,7 +2,7 @@ import { Actions } from '@hypothesis/frontend-shared'; ...@@ -2,7 +2,7 @@ import { Actions } from '@hypothesis/frontend-shared';
import classnames from 'classnames'; import classnames from 'classnames';
import { useStoreProxy } from '../../store/use-store'; import { useStoreProxy } from '../../store/use-store';
import { quote } from '../../helpers/annotation-metadata'; import { isSaved, quote } from '../../helpers/annotation-metadata';
import { withServices } from '../../service-context'; import { withServices } from '../../service-context';
import AnnotationActionBar from './AnnotationActionBar'; import AnnotationActionBar from './AnnotationActionBar';
...@@ -14,6 +14,7 @@ import AnnotationReplyToggle from './AnnotationReplyToggle'; ...@@ -14,6 +14,7 @@ import AnnotationReplyToggle from './AnnotationReplyToggle';
/** /**
* @typedef {import("../../../types/api").Annotation} Annotation * @typedef {import("../../../types/api").Annotation} Annotation
* @typedef {import("../../../types/api").SavedAnnotation} SavedAnnotation
* @typedef {import('../../../types/api').Group} Group * @typedef {import('../../../types/api').Group} Group
*/ */
...@@ -57,7 +58,11 @@ function Annotation({ ...@@ -57,7 +58,11 @@ function Annotation({
const showActions = !isSaving && !isEditing; const showActions = !isSaving && !isEditing;
const showReplyToggle = !isReply && !hasAppliedFilter && replyCount > 0; const showReplyToggle = !isReply && !hasAppliedFilter && replyCount > 0;
const onReply = () => annotationsService.reply(annotation, userid); const onReply = () => {
if (annotation && isSaved(annotation)) {
annotationsService.reply(annotation, userid);
}
};
return ( return (
<article <article
...@@ -111,7 +116,7 @@ function Annotation({ ...@@ -111,7 +116,7 @@ function Annotation({
Saving... Saving...
</div> </div>
)} )}
{annotation && showActions && ( {annotation && showActions && isSaved(annotation) && (
<Actions classes="hyp-u-stretch"> <Actions classes="hyp-u-stretch">
<AnnotationActionBar <AnnotationActionBar
annotation={annotation} annotation={annotation}
......
...@@ -13,13 +13,13 @@ import { useStoreProxy } from '../../store/use-store'; ...@@ -13,13 +13,13 @@ import { useStoreProxy } from '../../store/use-store';
import AnnotationShareControl from './AnnotationShareControl'; import AnnotationShareControl from './AnnotationShareControl';
/** /**
* @typedef {import("../../../types/api").Annotation} Annotation * @typedef {import("../../../types/api").SavedAnnotation} SavedAnnotation
* @typedef {import('../../../types/config').HostConfig} HostConfig * @typedef {import('../../../types/config').HostConfig} HostConfig
*/ */
/** /**
* @typedef AnnotationActionBarProps * @typedef AnnotationActionBarProps
* @prop {Annotation} annotation - The annotation in question * @prop {SavedAnnotation} annotation - The annotation in question
* @prop {() => any} onReply - Callbacks for when action buttons are clicked/tapped * @prop {() => any} onReply - Callbacks for when action buttons are clicked/tapped
* @prop {import('../../services/annotations').AnnotationsService} annotationsService * @prop {import('../../services/annotations').AnnotationsService} annotationsService
* @prop {HostConfig} settings * @prop {HostConfig} settings
......
...@@ -2,9 +2,12 @@ ...@@ -2,9 +2,12 @@
* Utility functions for querying annotation metadata. * Utility functions for querying annotation metadata.
*/ */
/** @typedef {import('../../types/api').Annotation} Annotation */ /**
/** @typedef {import('../../types/api').TextPositionSelector} TextPositionSelector */ * @typedef {import('../../types/api').Annotation} Annotation
/** @typedef {import('../../types/api').TextQuoteSelector} TextQuoteSelector */ * @typedef {import('../../types/api').SavedAnnotation} SavedAnnotation
* @typedef {import('../../types/api').TextPositionSelector} TextPositionSelector
* @typedef {import('../../types/api').TextQuoteSelector} TextQuoteSelector
*/
/** /**
* Extract document metadata from an annotation. * Extract document metadata from an annotation.
...@@ -123,11 +126,21 @@ export function isReply(annotation) { ...@@ -123,11 +126,21 @@ export function isReply(annotation) {
return (annotation.references || []).length > 0; return (annotation.references || []).length > 0;
} }
/** Return `true` if the given annotation is new, `false` otherwise. /**
* Return true if the given annotation has been saved to the backend and assigned
* an ID.
* *
* "New" means this annotation has been newly created client-side and not * @param {Annotation} annotation
* saved to the server yet. * @return {annotation is SavedAnnotation}
*/
export function isSaved(annotation) {
return !!annotation.id;
}
/**
* Return true if an annotation has not been saved to the backend.
* *
* @deprecated - Use {@link isSaved} instead
* @param {Annotation} annotation * @param {Annotation} annotation
*/ */
export function isNew(annotation) { export function isNew(annotation) {
......
import * as fixtures from '../../test/annotation-fixtures'; import * as fixtures from '../../test/annotation-fixtures';
import * as annotationMetadata from '../annotation-metadata'; import * as annotationMetadata from '../annotation-metadata';
import {
const documentMetadata = annotationMetadata.documentMetadata; documentMetadata,
const domainAndTitle = annotationMetadata.domainAndTitle; domainAndTitle,
isSaved,
} from '../annotation-metadata';
describe('sidebar/helpers/annotation-metadata', () => { describe('sidebar/helpers/annotation-metadata', () => {
const fakeAnnotation = (props = {}) => { const fakeAnnotation = (props = {}) => {
...@@ -452,6 +454,16 @@ describe('sidebar/helpers/annotation-metadata', () => { ...@@ -452,6 +454,16 @@ describe('sidebar/helpers/annotation-metadata', () => {
}); });
}); });
describe('isSaved', () => {
it('returns true for saved annotations', () => {
assert.isTrue(isSaved(fixtures.defaultAnnotation()));
});
it('returns false for unsaved annotations', () => {
assert.isFalse(isSaved(fixtures.newAnnotation()));
});
});
describe('flagCount', () => { describe('flagCount', () => {
const flagCount = annotationMetadata.flagCount; const flagCount = annotationMetadata.flagCount;
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
/** /**
* @typedef {import('../../types/api').Annotation} Annotation * @typedef {import('../../types/api').Annotation} Annotation
* @typedef {import('../../types/annotator').AnnotationData} AnnotationData * @typedef {import('../../types/annotator').AnnotationData} AnnotationData
* @typedef {import('../../types/api').SavedAnnotation} SavedAnnotation
*/ */
/** /**
...@@ -51,7 +52,7 @@ export class AnnotationsService { ...@@ -51,7 +52,7 @@ export class AnnotationsService {
/** /**
* Extend new annotation objects with defaults and permissions. * Extend new annotation objects with defaults and permissions.
* *
* @param {AnnotationData} annotationData * @param {Omit<AnnotationData, '$tag'>} annotationData
* @param {Date} now * @param {Date} now
* @return {Annotation} * @return {Annotation}
*/ */
...@@ -103,7 +104,7 @@ export class AnnotationsService { ...@@ -103,7 +104,7 @@ export class AnnotationsService {
* Create a draft for it unless it's a highlight and clear other empty * Create a draft for it unless it's a highlight and clear other empty
* drafts out of the way. * drafts out of the way.
* *
* @param {object} annotationData * @param {Omit<AnnotationData, '$tag'>} annotationData
* @param {Date} now * @param {Date} now
*/ */
create(annotationData, now = new Date()) { create(annotationData, now = new Date()) {
...@@ -171,6 +172,8 @@ export class AnnotationsService { ...@@ -171,6 +172,8 @@ export class AnnotationsService {
/** /**
* Flag an annotation for review by a moderator. * Flag an annotation for review by a moderator.
*
* @param {SavedAnnotation} annotation
*/ */
async flag(annotation) { async flag(annotation) {
await this._api.annotation.flag({ id: annotation.id }); await this._api.annotation.flag({ id: annotation.id });
...@@ -180,7 +183,7 @@ export class AnnotationsService { ...@@ -180,7 +183,7 @@ export class AnnotationsService {
/** /**
* Create a reply to `annotation` by the user `userid` and add to the store. * Create a reply to `annotation` by the user `userid` and add to the store.
* *
* @param {object} annotation * @param {SavedAnnotation} annotation
* @param {string} userid * @param {string} userid
*/ */
reply(annotation, userid) { reply(annotation, userid) {
...@@ -200,13 +203,15 @@ export class AnnotationsService { ...@@ -200,13 +203,15 @@ export class AnnotationsService {
* Save new (or update existing) annotation. On success, * Save new (or update existing) annotation. On success,
* the annotation's `Draft` will be removed and the annotation added * the annotation's `Draft` will be removed and the annotation added
* to the store. * to the store.
*
* @param {Annotation} annotation
*/ */
async save(annotation) { async save(annotation) {
let saved; let saved;
const annotationWithChanges = this._applyDraftChanges(annotation); const annotationWithChanges = this._applyDraftChanges(annotation);
if (metadata.isNew(annotation)) { if (!metadata.isSaved(annotation)) {
saved = this._api.annotation.create({}, annotationWithChanges); saved = this._api.annotation.create({}, annotationWithChanges);
} else { } else {
saved = this._api.annotation.update( saved = this._api.annotation.update(
......
...@@ -30,7 +30,7 @@ describe('AnnotationsService', () => { ...@@ -30,7 +30,7 @@ describe('AnnotationsService', () => {
fakeMetadata = { fakeMetadata = {
isAnnotation: sinon.stub(), isAnnotation: sinon.stub(),
isHighlight: sinon.stub(), isHighlight: sinon.stub(),
isNew: sinon.stub(), isSaved: sinon.stub(),
isPageNote: sinon.stub(), isPageNote: sinon.stub(),
isPublic: sinon.stub(), isPublic: sinon.stub(),
}; };
...@@ -370,7 +370,7 @@ describe('AnnotationsService', () => { ...@@ -370,7 +370,7 @@ describe('AnnotationsService', () => {
describe('save', () => { describe('save', () => {
it('calls the `create` API service for new annotations', () => { it('calls the `create` API service for new annotations', () => {
fakeMetadata.isNew.returns(true); fakeMetadata.isSaved.returns(false);
// Using the new-annotation fixture has no bearing on which API method // Using the new-annotation fixture has no bearing on which API method
// will get called because `isNew` is mocked, but it has representative // will get called because `isNew` is mocked, but it has representative
// properties // properties
...@@ -384,7 +384,7 @@ describe('AnnotationsService', () => { ...@@ -384,7 +384,7 @@ describe('AnnotationsService', () => {
}); });
it('calls the `update` API service for pre-existing annotations', () => { it('calls the `update` API service for pre-existing annotations', () => {
fakeMetadata.isNew.returns(false); fakeMetadata.isSaved.returns(true);
const annotation = fixtures.defaultAnnotation(); const annotation = fixtures.defaultAnnotation();
return svc.save(annotation).then(() => { return svc.save(annotation).then(() => {
...@@ -396,7 +396,7 @@ describe('AnnotationsService', () => { ...@@ -396,7 +396,7 @@ describe('AnnotationsService', () => {
}); });
it('calls the relevant API service with an object that has any draft changes integrated', () => { it('calls the relevant API service with an object that has any draft changes integrated', () => {
fakeMetadata.isNew.returns(true); fakeMetadata.isSaved.returns(false);
fakePrivatePermissions.returns({ read: ['foo'] }); fakePrivatePermissions.returns({ read: ['foo'] });
const annotation = fixtures.defaultAnnotation(); const annotation = fixtures.defaultAnnotation();
annotation.text = 'not this'; annotation.text = 'not this';
...@@ -424,7 +424,7 @@ describe('AnnotationsService', () => { ...@@ -424,7 +424,7 @@ describe('AnnotationsService', () => {
context('successful save', () => { context('successful save', () => {
it('copies over internal app-specific keys to the annotation object', () => { it('copies over internal app-specific keys to the annotation object', () => {
fakeMetadata.isNew.returns(false); fakeMetadata.isSaved.returns(true);
const annotation = fixtures.defaultAnnotation(); const annotation = fixtures.defaultAnnotation();
annotation.$tag = 'mytag'; annotation.$tag = 'mytag';
annotation.$foo = 'bar'; annotation.$foo = 'bar';
...@@ -450,7 +450,7 @@ describe('AnnotationsService', () => { ...@@ -450,7 +450,7 @@ describe('AnnotationsService', () => {
it('adds the updated annotation to the store', () => { it('adds the updated annotation to the store', () => {
const annotation = fixtures.defaultAnnotation(); const annotation = fixtures.defaultAnnotation();
fakeMetadata.isNew.returns(false); fakeMetadata.isSaved.returns(true);
fakeApi.annotation.update.resolves(annotation); fakeApi.annotation.update.resolves(annotation);
return svc.save(annotation).then(() => { return svc.save(annotation).then(() => {
...@@ -462,7 +462,7 @@ describe('AnnotationsService', () => { ...@@ -462,7 +462,7 @@ describe('AnnotationsService', () => {
context('error on save', () => { context('error on save', () => {
it('removes the active save request from the store', () => { it('removes the active save request from the store', () => {
fakeApi.annotation.update.rejects(); fakeApi.annotation.update.rejects();
fakeMetadata.isNew.returns(false); fakeMetadata.isSaved.returns(true);
return svc.save(fixtures.defaultAnnotation()).catch(() => { return svc.save(fixtures.defaultAnnotation()).catch(() => {
assert.notCalled(fakeStore.removeDraft); assert.notCalled(fakeStore.removeDraft);
...@@ -472,7 +472,7 @@ describe('AnnotationsService', () => { ...@@ -472,7 +472,7 @@ describe('AnnotationsService', () => {
it('does not remove the annotation draft', () => { it('does not remove the annotation draft', () => {
fakeApi.annotation.update.rejects(); fakeApi.annotation.update.rejects();
fakeMetadata.isNew.returns(false); fakeMetadata.isSaved.returns(true);
return svc.save(fixtures.defaultAnnotation()).catch(() => { return svc.save(fixtures.defaultAnnotation()).catch(() => {
assert.notCalled(fakeStore.removeDraft); assert.notCalled(fakeStore.removeDraft);
...@@ -481,7 +481,7 @@ describe('AnnotationsService', () => { ...@@ -481,7 +481,7 @@ describe('AnnotationsService', () => {
it('does not add the annotation to the store', () => { it('does not add the annotation to the store', () => {
fakeApi.annotation.update.rejects(); fakeApi.annotation.update.rejects();
fakeMetadata.isNew.returns(false); fakeMetadata.isSaved.returns(true);
return svc.save(fixtures.defaultAnnotation()).catch(() => { return svc.save(fixtures.defaultAnnotation()).catch(() => {
assert.notCalled(fakeStore.addAnnotations); assert.notCalled(fakeStore.addAnnotations);
......
...@@ -109,6 +109,12 @@ ...@@ -109,6 +109,12 @@
* @prop {boolean} [$anchorTimeout] * @prop {boolean} [$anchorTimeout]
*/ */
/**
* An annotation which has been saved to the backend and assigned an ID.
*
* @typedef {Annotation & { id: string }} SavedAnnotation
*/
/** /**
* @typedef Profile * @typedef Profile
* @prop {string|null} userid * @prop {string|null} userid
......
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