Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/@types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export interface Permissions {
}

export type TokenLiteral = null | undefined | string | { read: string; write?: string };
export type TokenResolver = () => TokenLiteral | Promise<TokenLiteral>;
export type Token = TokenLiteral | TokenResolver;
export type TokenMap = { [typedFileId: string]: TokenLiteral };
export type TokenResolver = (typedFileId?: string) => TokenLiteral | TokenMap | Promise<TokenLiteral | TokenMap>;
export type Token = TokenLiteral | TokenMap | TokenResolver;
14 changes: 13 additions & 1 deletion src/@types/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ enum Event {
CREATOR_STAGED_CHANGE = 'creator_staged_change',
CREATOR_STATUS_CHANGE = 'creator_status_change',
ANNOTATION_CREATE = 'annotations_create',
ANNOTATION_DELETE = 'annotations_delete',
ANNOTATION_FETCH_ERROR = 'annotations_fetch_error',
ANNOTATION_REMOVE = 'annotations_remove',
ANNOTATION_REPLY_CREATE = 'annotations_reply_create',
ANNOTATION_REPLY_DELETE = 'annotations_reply_delete',
ANNOTATION_REPLY_UPDATE = 'annotations_reply_update',
ANNOTATION_UPDATE = 'annotations_update',
ANNOTATIONS_INITIALIZED = 'annotations_initialized',
ANNOTATIONS_MODE_CHANGE = 'annotations_mode_change',
COLOR_SET = 'annotations_color_set',
Expand All @@ -16,11 +21,18 @@ enum Event {
VIEW_MODE_SET = 'view_mode_set',
}

enum SidebarEvent {
SIDEBAR_ANNOTATION_UPDATE = 'sidebar.annotations_update',
SIDEBAR_REPLY_CREATE = 'sidebar.annotations_reply_create',
SIDEBAR_REPLY_DELETE = 'sidebar.annotations_reply_delete',
SIDEBAR_REPLY_UPDATE = 'sidebar.annotations_reply_update',
}

// Existing legacy events, don't rename
enum LegacyEvent {
ANNOTATOR = 'annotatorevent',
ERROR = 'annotationerror',
SCALE = 'scaleannotations',
}

export { Event, LegacyEvent };
export { Event, LegacyEvent, SidebarEvent };
1 change: 1 addition & 0 deletions src/@types/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export interface Reply {
created_by: User;
id: string;
message: string;
modified_at?: string;
parent: {
id: string;
type: string;
Expand Down
37 changes: 36 additions & 1 deletion src/adapters/__tests__/threadedAnnotationsAdapters-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,20 +144,55 @@ describe('threadedAnnotationsAdapters', () => {
});
});

test('should map reply permissions from backend payload, forcing canEdit false', () => {
test('should map reply permissions from backend payload', () => {
const reply: Reply = {
...mockReply,
permissions: { can_delete: true, can_edit: true, can_reply: true, can_resolve: true },
};
const result = replyToTextMessage(reply);

expect(result.permissions).toEqual({
canDelete: true,
canEdit: true,
canReply: true,
canResolve: true,
});
});

test('should default canEdit to false when backend payload omits it', () => {
const reply: Reply = {
...mockReply,
permissions: { can_delete: true, can_reply: true, can_resolve: true },
};
const result = replyToTextMessage(reply);

expect(result.permissions).toEqual({
canDelete: true,
canEdit: false,
canReply: true,
canResolve: true,
});
});

test('should leave updatedAt undefined when reply has no modified_at', () => {
const result = replyToTextMessage(mockReply);

expect(result.updatedAt).toBeUndefined();
});

test('should leave updatedAt undefined when modified_at equals created_at', () => {
const reply: Reply = { ...mockReply, modified_at: mockReply.created_at };
const result = replyToTextMessage(reply);

expect(result.updatedAt).toBeUndefined();
});

test('should set updatedAt to modified_at instant when reply was edited', () => {
const reply: Reply = { ...mockReply, modified_at: '2026-03-15T11:00:00Z' };
const result = replyToTextMessage(reply);

expect(result.updatedAt).toBe(new Date('2026-03-15T11:00:00Z').getTime());
});
});

describe('annotationToMessages', () => {
Expand Down
30 changes: 16 additions & 14 deletions src/adapters/threadedAnnotationsAdapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ export const deserializeMentionMarkup = (text: string): DocumentNodeV2 => {
return { type: 'doc', content };
};

/**
* Returns the edit timestamp consumers use to render an edited indicator.
* Compares parsed instants, not raw strings, so equivalent ISO formats
* (Z vs +00:00, fractional precision) are treated as unedited.
*/
const toUpdatedAt = (createdAt: string, modifiedAt: string | undefined): number | undefined => {
if (!modifiedAt) return undefined;
const modifiedMs = new Date(modifiedAt).getTime();
if (Number.isNaN(modifiedMs)) return undefined;
const createdMs = new Date(createdAt).getTime();
if (modifiedMs === createdMs) return undefined;
return modifiedMs;
};

/**
* Converts a box-annotations Reply to a threaded-annotations TextMessageType.
*/
Expand All @@ -83,25 +97,13 @@ export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({
message: deserializeMentionMarkup(reply.message),
permissions: {
canDelete: reply.permissions?.can_delete ?? false,
canEdit: false,
canEdit: reply.permissions?.can_edit ?? false,
canReply: reply.permissions?.can_reply ?? false,
canResolve: reply.permissions?.can_resolve ?? false,
},
updatedAt: toUpdatedAt(reply.created_at, reply.modified_at),
});

/**
* Returns the edit timestamp consumers use to render an edited indicator.
* Compares parsed instants, not raw strings, so equivalent ISO formats
* (Z vs +00:00, fractional precision) are treated as unedited.
*/
const toUpdatedAt = (createdAt: string, modifiedAt: string): number | undefined => {
const modifiedMs = new Date(modifiedAt).getTime();
if (Number.isNaN(modifiedMs)) return undefined;
const createdMs = new Date(createdAt).getTime();
if (modifiedMs === createdMs) return undefined;
return modifiedMs;
};

// The root message shares the annotation's author and permissions; description
// comes back sparse ({ message } only) from the list endpoint.
const descriptionToTextMessage = (annotation: Annotation): TextMessageTypeV2 => ({
Expand Down
7 changes: 6 additions & 1 deletion src/api/APIFactory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Annotations from 'box-ui-elements/es/api/Annotations';
import FileCollaborators from 'box-ui-elements/es/api/FileCollaborators';
import ThreadedComments from 'box-ui-elements/es/api/ThreadedComments';
import { DEFAULT_HOSTNAME_API } from 'box-ui-elements/es/constants';
import { AnnotationsAPI, CollaboratorsAPI, APIOptions } from './types';
import { AnnotationsAPI, CollaboratorsAPI, APIOptions, ThreadedCommentsAPI } from './types';

export default class APIFactory {
options: APIOptions;
Expand All @@ -21,4 +22,8 @@ export default class APIFactory {
getCollaboratorsAPI(): CollaboratorsAPI {
return new FileCollaborators(this.options);
}

getThreadedCommentsAPI(): ThreadedCommentsAPI {
return new ThreadedComments(this.options);
}
}
5 changes: 5 additions & 0 deletions src/api/__mocks__/APIFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ export default jest.fn(() => ({
),
destroy: jest.fn(),
})),
getThreadedCommentsAPI: jest.fn(() => ({
deleteComment: jest.fn(({ successCallback }) => successCallback()),
updateComment: jest.fn(({ successCallback }) => successCallback({ id: 'reply_1', message: 'updated' })),
destroy: jest.fn(),
})),
}));
24 changes: 23 additions & 1 deletion src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Annotation, Collaborator, NewAnnotation, Permissions, Reply, Token } from '../@types';
import { Annotation, Collaborator, NewAnnotation, Permissions, Reply, ReplyPermissions, Token } from '../@types';

export type APICollection<R> = {
entries: R[];
Expand Down Expand Up @@ -73,6 +73,28 @@ export interface AnnotationsAPI {
destroy(): void;
}

export interface ThreadedCommentsAPI {
deleteComment(args: {
commentId: string;
errorCallback: (error: APIError) => void;
fileId: string | null;
permissions: ReplyPermissions;
successCallback: () => void;
}): void;

updateComment(args: {
commentId: string;
errorCallback: (error: APIError) => void;
fileId: string | null;
message?: string;
permissions: ReplyPermissions;
status?: string;
successCallback: (reply: Reply) => void;
}): void;

destroy(): void;
}

export interface CollaboratorsAPI {
getFileCollaborators(
fileId: string | null,
Expand Down
29 changes: 27 additions & 2 deletions src/common/BaseAnnotator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import DeselectManager from './DeselectManager';
import EventEmitter from './EventEmitter';
import i18n from '../utils/i18n';
import messages from '../messages';
import { Event, IntlOptions, LegacyEvent, Permissions } from '../@types';
import { Event, IntlOptions, LegacyEvent, Permissions, SidebarEvent, Token } from '../@types';
import { BoundingBox, getBoundingBoxHighlights } from '../store/boundingBoxHighlights';
import { ViewMode } from '../store/options/types';
import { Features } from '../BoxAnnotations';
Expand Down Expand Up @@ -45,7 +45,7 @@ export type Options = {
intl: IntlOptions;
locale?: string;
onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void;
token: string;
token: Token;
};

export const CSS_CONTAINER_CLASS = 'ba';
Expand Down Expand Up @@ -131,6 +131,11 @@ export default class BaseAnnotator extends EventEmitter {
this.addListener(Event.BOUNDING_BOX_HIGHLIGHT_SELECT, this.handleSelectBoundingBoxHighlight);
this.addListener(Event.VIEW_MODE_SET, this.handleSetViewMode);

this.addListener(SidebarEvent.SIDEBAR_ANNOTATION_UPDATE, this.handleSidebarAnnotationUpdate);
this.addListener(SidebarEvent.SIDEBAR_REPLY_CREATE, this.handleSidebarReplyCreate);
this.addListener(SidebarEvent.SIDEBAR_REPLY_UPDATE, this.handleSidebarReplyUpdate);
this.addListener(SidebarEvent.SIDEBAR_REPLY_DELETE, this.handleSidebarReplyDelete);

// Load any required data at startup
this.hydrate();
}
Expand Down Expand Up @@ -164,6 +169,10 @@ export default class BaseAnnotator extends EventEmitter {
this.removeListener(Event.BOUNDING_BOX_HIGHLIGHT_NAVIGATE, this.handleNavigateBoundingBoxHighlight);
this.removeListener(Event.BOUNDING_BOX_HIGHLIGHT_SELECT, this.handleSelectBoundingBoxHighlight);
this.removeListener(Event.VIEW_MODE_SET, this.handleSetViewMode);
this.removeListener(SidebarEvent.SIDEBAR_ANNOTATION_UPDATE, this.handleSidebarAnnotationUpdate);
this.removeListener(SidebarEvent.SIDEBAR_REPLY_CREATE, this.handleSidebarReplyCreate);
this.removeListener(SidebarEvent.SIDEBAR_REPLY_UPDATE, this.handleSidebarReplyUpdate);
this.removeListener(SidebarEvent.SIDEBAR_REPLY_DELETE, this.handleSidebarReplyDelete);
}

public init(scale = 1, rotation = 0): void {
Expand Down Expand Up @@ -357,6 +366,22 @@ export default class BaseAnnotator extends EventEmitter {
this.setViewMode(viewMode);
};

protected handleSidebarAnnotationUpdate = (annotation: store.SidebarAnnotationUpdatePayload): void => {
this.store.dispatch(store.applySidebarAnnotationUpdateAction(annotation));
};

protected handleSidebarReplyCreate = (payload: store.SidebarReplyMutationPayload): void => {
this.store.dispatch(store.applySidebarReplyCreateAction(payload));
};

protected handleSidebarReplyUpdate = (payload: store.SidebarReplyMutationPayload): void => {
this.store.dispatch(store.applySidebarReplyUpdateAction(payload));
};

protected handleSidebarReplyDelete = ({ annotationId, id }: { annotationId: string; id: string }): void => {
this.store.dispatch(store.applySidebarReplyDeleteAction({ annotationId, replyId: id }));
};

protected hydrate(): void {
// Redux dispatch method signature doesn't seem to like async actions
this.store.dispatch<any>(store.fetchAnnotationsAction()); // eslint-disable-line @typescript-eslint/no-explicit-any
Expand Down
38 changes: 37 additions & 1 deletion src/common/__tests__/BaseAnnotator-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import APIFactory from '../../api';
import BaseAnnotator, { ANNOTATION_CLASSES, CSS_CONTAINER_CLASS, CSS_LOADED_CLASS } from '../BaseAnnotator';
import DeselectManager from '../DeselectManager';
import { ANNOTATOR_EVENT } from '../../constants';
import { Event, LegacyEvent } from '../../@types';
import { Event, LegacyEvent, SidebarEvent } from '../../@types';
import { Mode } from '../../store/common';
import { setIsInitialized } from '../../store';

Expand Down Expand Up @@ -220,6 +220,10 @@ describe('BaseAnnotator', () => {
expect(annotator.removeListener).toBeCalledWith(Event.COLOR_SET, expect.any(Function));
expect(annotator.removeListener).toBeCalledWith(Event.VISIBLE_SET, expect.any(Function));
expect(annotator.removeListener).toBeCalledWith(LegacyEvent.SCALE, expect.any(Function));
expect(annotator.removeListener).toBeCalledWith(SidebarEvent.SIDEBAR_ANNOTATION_UPDATE, expect.any(Function));
expect(annotator.removeListener).toBeCalledWith(SidebarEvent.SIDEBAR_REPLY_CREATE, expect.any(Function));
expect(annotator.removeListener).toBeCalledWith(SidebarEvent.SIDEBAR_REPLY_UPDATE, expect.any(Function));
expect(annotator.removeListener).toBeCalledWith(SidebarEvent.SIDEBAR_REPLY_DELETE, expect.any(Function));
});

test('should destroy DeselectManager', () => {
Expand Down Expand Up @@ -284,6 +288,38 @@ describe('BaseAnnotator', () => {
annotator.emit(Event.COLOR_SET, '#000');
expect(annotator.setColor).toHaveBeenCalledWith('#000');
});

test('should dispatch applySidebarAnnotationUpdate when sidebar emits annotation update', () => {
const partial = { id: 'anno_1', status: 'resolved' as const };

annotator.emit(SidebarEvent.SIDEBAR_ANNOTATION_UPDATE, partial);

expect(annotator.store.dispatch).toHaveBeenCalledWith(store.applySidebarAnnotationUpdateAction(partial));
});

test('should dispatch applySidebarReplyCreate when sidebar emits reply create', () => {
const payload = { annotationId: 'anno_1', reply: { id: 'r1' } as never };

annotator.emit(SidebarEvent.SIDEBAR_REPLY_CREATE, payload);

expect(annotator.store.dispatch).toHaveBeenCalledWith(store.applySidebarReplyCreateAction(payload));
});

test('should dispatch applySidebarReplyUpdate when sidebar emits reply update', () => {
const payload = { annotationId: 'anno_1', reply: { id: 'r1', message: 'updated' } as never };

annotator.emit(SidebarEvent.SIDEBAR_REPLY_UPDATE, payload);

expect(annotator.store.dispatch).toHaveBeenCalledWith(store.applySidebarReplyUpdateAction(payload));
});

test('should translate sidebar emit `id` to action payload `replyId` and dispatch applySidebarReplyDelete', () => {
annotator.emit(SidebarEvent.SIDEBAR_REPLY_DELETE, { annotationId: 'anno_1', id: 'r1' });

expect(annotator.store.dispatch).toHaveBeenCalledWith(
store.applySidebarReplyDeleteAction({ annotationId: 'anno_1', replyId: 'r1' }),
);
});
});

describe('scrollToAnnotation()', () => {
Expand Down
Loading