Skip to content
Merged
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
24 changes: 12 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@
"@babel/preset-react": "^7.25.9",
"@babel/preset-typescript": "^7.25.9",
"@box/frontend": "^10.0.0",
"@box/blueprint-web": "^14.27.0",
"@box/blueprint-web-assets": "^4.116.6",
"@box/collaboration-popover": "^1.61.30",
"@box/blueprint-web": "^14.30.0",
"@box/blueprint-web-assets": "^4.117.2",
"@box/collaboration-popover": "^1.62.3",
"@box/languages": "^1.1.0",
"@box/readable-time": "^1.40.30",
"@box/threaded-annotations": "^1.86.0",
"@box/user-selector": "^1.75.30",
"@box/readable-time": "^1.41.3",
"@box/threaded-annotations": "^1.89.1",
"@box/user-selector": "^1.76.3",
"@cfaester/enzyme-adapter-react-18": "^0.8.0",
"@commitlint/cli": "^8.3.5",
"@commitlint/config-conventional": "^8.2.0",
Expand Down Expand Up @@ -128,12 +128,12 @@
"worker-farm": "^1.7.0"
},
"peerDependencies": {
"@box/blueprint-web": "^14.27.0",
"@box/blueprint-web-assets": "^4.116.6",
"@box/collaboration-popover": "^1.61.30",
"@box/readable-time": "^1.40.30",
"@box/threaded-annotations": "^1.86.0",
"@box/user-selector": "^1.75.30"
"@box/blueprint-web": "^14.30.0",
"@box/blueprint-web-assets": "^4.117.2",
"@box/collaboration-popover": "^1.62.3",
"@box/readable-time": "^1.41.3",
"@box/threaded-annotations": "^1.89.1",
"@box/user-selector": "^1.76.3"
},
"scripts": {
"build": "yarn setup && yarn build:prod:dist",
Expand Down
8 changes: 8 additions & 0 deletions src/@types/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ export interface Rect extends Position {
width: number;
}

export interface ReplyPermissions {
can_delete?: boolean;
can_edit?: boolean;
can_reply?: boolean;
can_resolve?: boolean;
}

export interface Reply {
created_at: string;
created_by: User;
Expand All @@ -95,6 +102,7 @@ export interface Reply {
id: string;
type: string;
};
permissions?: ReplyPermissions;
type: 'reply';
}

Expand Down
1 change: 1 addition & 0 deletions src/BoxAnnotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Annotator = {
type AnnotationsOptions = {
features: Features;
intl: IntlOptions;
onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void;
};

export type Features = {
Expand Down
79 changes: 27 additions & 52 deletions src/adapters/__tests__/threadedAnnotationsAdapters-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,31 @@ describe('threadedAnnotationsAdapters', () => {
});
});

test('should set default permissions for replies', () => {
test('should default permissions to false when reply has no permissions field', () => {
const result = replyToTextMessage(mockReply);

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

test('should map reply permissions from backend payload, forcing canEdit false', () => {
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: false,
canReply: true,
canResolve: true,
});
});
});

describe('annotationToMessages', () => {
Expand All @@ -162,37 +177,27 @@ describe('threadedAnnotationsAdapters', () => {
expect(result).toEqual([]);
});

test('should include description as first message', () => {
test('should include description as first message using annotation-level author and permissions', () => {
const annotation: Annotation = {
...baseAnnotation,
description: {
created_at: '2026-01-01T00:00:00Z',
created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' },
id: 'desc-1',
message: 'Root message',
parent: { id: 'ann-1', type: 'annotation' },
type: 'reply',
},
description: { message: 'Root message' } as unknown as Reply,
};
const result = annotationToMessages(annotation);

expect(result).toHaveLength(1);
expect(result[0].id).toBe('desc-1');
expect(result[0].id).toBe('ann-1');
expect(result[0].author.name).toBe('User');
expect(result[0].author.email).toBe('user@box.com');
expect(result[0].author.id).toBe(1);
expect(result[0].createdAt).toBe(new Date('2026-01-01T00:00:00Z').getTime());
expect(result[0].permissions.canDelete).toBe(true);
expect(result[0].permissions.canEdit).toBe(true);
});

test('should include description and replies in order', () => {
const annotation: Annotation = {
...baseAnnotation,
description: {
created_at: '2026-01-01T00:00:00Z',
created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' },
id: 'desc-1',
message: 'Root',
parent: { id: 'ann-1', type: 'annotation' },
type: 'reply',
},
description: { message: 'Root' } as unknown as Reply,
replies: [
{
created_at: '2026-01-02T00:00:00Z',
Expand All @@ -207,42 +212,12 @@ describe('threadedAnnotationsAdapters', () => {
const result = annotationToMessages(annotation);

expect(result).toHaveLength(2);
expect(result[0].id).toBe('desc-1');
expect(result[1].id).toBe('reply-1');
});

test('should fall back to annotation fields when description is sparse', () => {
const annotation: Annotation = {
...baseAnnotation,
description: {
message: 'Sparse description',
} as unknown as Reply,
};
const result = annotationToMessages(annotation);

expect(result).toHaveLength(1);
expect(result[0].id).toBe('ann-1');
expect(result[1].id).toBe('reply-1');
expect(result[0].author.name).toBe('User');
expect(result[0].author.email).toBe('user@box.com');
expect(result[0].author.id).toBe(1);
expect(result[0].createdAt).toBe(new Date('2026-01-01T00:00:00Z').getTime());
expect(result[1].author.name).toBe('Other');
});

test('should handle description with missing created_by gracefully', () => {
const annotation: Annotation = {
...baseAnnotation,
description: {
id: 'desc-1',
message: 'Test',
created_at: '2026-01-01T00:00:00Z',
} as unknown as Reply,
};
const result = annotationToMessages(annotation);

expect(result).toHaveLength(1);
expect(result[0].author.name).toBe('User');
expect(result[0].author.email).toBe('user@box.com');
});
});

describe('collaboratorToUserContact', () => {
Expand Down
35 changes: 14 additions & 21 deletions src/adapters/threadedAnnotationsAdapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,35 +82,28 @@ export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({
id: reply.id,
message: deserializeMentionMarkup(reply.message),
permissions: {
canDelete: false,
canDelete: reply.permissions?.can_delete ?? false,
canEdit: false,
canReply: true,
canResolve: false,
canReply: reply.permissions?.can_reply ?? false,
canResolve: reply.permissions?.can_resolve ?? false,
},
});

/**
* Converts an Annotation's description (root message) to a TextMessageType.
* The root message gets resolve permission from the annotation's permissions.
* Falls back to annotation-level fields when the description object is sparse
* (the list endpoint returns description with only { message }).
*/
const descriptionToTextMessage = (
annotation: Annotation,
reply: Reply,
): TextMessageTypeV2 => ({
// 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 => ({
author: {
email: (reply.created_by ?? annotation.created_by)?.login ?? '',
id: parseInt((reply.created_by ?? annotation.created_by)?.id ?? '0', 10),
name: (reply.created_by ?? annotation.created_by)?.name ?? '',
email: annotation.created_by?.login ?? '',
id: parseInt(annotation.created_by?.id ?? '0', 10),
name: annotation.created_by?.name ?? '',
},
createdAt: new Date(reply.created_at ?? annotation.created_at).getTime(),
id: reply.id ?? annotation.id,
message: deserializeMentionMarkup(reply.message),
createdAt: new Date(annotation.created_at).getTime(),
id: annotation.id,
message: deserializeMentionMarkup(annotation.description?.message ?? ''),
permissions: {
canDelete: annotation.permissions?.can_delete ?? false,
canEdit: annotation.permissions?.can_edit ?? false,
canReply: true,
canReply: annotation.permissions?.can_reply ?? false,
canResolve: annotation.permissions?.can_resolve ?? false,
},
});
Expand All @@ -123,7 +116,7 @@ export const annotationToMessages = (annotation: Annotation): TextMessageTypeV2[
const messages: TextMessageTypeV2[] = [];

if (annotation.description) {
messages.push(descriptionToTextMessage(annotation, annotation.description));
messages.push(descriptionToTextMessage(annotation));
}

if (annotation.replies) {
Expand Down
7 changes: 7 additions & 0 deletions src/common/AnnotationCallbacksContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createContext } from 'react';

export type AnnotationCallbacks = {
onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void;
};

export default createContext<AnnotationCallbacks>({});
5 changes: 5 additions & 0 deletions src/common/BaseAnnotator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type Options = {
initialViewMode?: ViewMode;
intl: IntlOptions;
locale?: string;
onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void;
token: string;
};

Expand All @@ -70,6 +71,8 @@ export default class BaseAnnotator extends EventEmitter {

intl: IntlShape;

onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void;

store: store.AppStore;

constructor({
Expand All @@ -82,6 +85,7 @@ export default class BaseAnnotator extends EventEmitter {
initialMode,
initialViewMode = 'annotations',
intl,
onCopyLink,
token,
}: Options) {
super();
Expand Down Expand Up @@ -111,6 +115,7 @@ export default class BaseAnnotator extends EventEmitter {
this.container = container;
this.features = features;
this.intl = i18n.createIntlProvider(intl);
this.onCopyLink = onCopyLink;
this.store = store.createStore(initialState, {
api: new API({ apiHost, token }),
});
Expand Down
2 changes: 2 additions & 0 deletions src/common/BaseManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as ReactDOM from 'react-dom/client';
import { IntlShape } from 'react-intl';
import { Store } from 'redux';
import { AnnotationCallbacks } from './AnnotationCallbacksContext';
import { applyResinTags } from '../utils/resin';
import { TARGET_TYPE } from '../constants';

Expand All @@ -14,6 +15,7 @@ export type Options = {
};

export type Props = {
callbacks?: AnnotationCallbacks;
intl: IntlShape;
store: Store;
};
Expand Down
13 changes: 13 additions & 0 deletions src/common/__tests__/BaseAnnotator-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,19 @@ describe('BaseAnnotator', () => {
expect(annotator.features).toEqual(features);
});

test('should store onCopyLink callback when provided', () => {
const onCopyLink = jest.fn();
initAnnotator({ onCopyLink });

expect(annotator.onCopyLink).toBe(onCopyLink);
});

test('should leave onCopyLink undefined when not provided', () => {
initAnnotator();

expect(annotator.onCopyLink).toBeUndefined();
});

test('should set initial mode and color', () => {
initAnnotator({ initialColor: '#000', initialMode: Mode.REGION });

Expand Down
8 changes: 6 additions & 2 deletions src/common/withProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import * as React from 'react';
import { IntlShape, RawIntlProvider } from 'react-intl';
import { Provider as StoreProvider } from 'react-redux';
import { Store } from 'redux';
import AnnotationCallbacksContext, { AnnotationCallbacks } from './AnnotationCallbacksContext';

type WrapperProps = {
callbacks?: AnnotationCallbacks;
intl: IntlShape;
store: Store;
};

type WrapperReturn<P> = React.FC<P & WrapperProps>;

export default function withProviders<P extends object>(WrappedComponent: React.ComponentType<P>): WrapperReturn<P> {
return function RootProvider({ intl, store, ...rest }: P & WrapperProps): JSX.Element {
return function RootProvider({ callbacks, intl, store, ...rest }: P & WrapperProps): JSX.Element {
return (
<RawIntlProvider value={intl}>
<StoreProvider store={store}>
<WrappedComponent {...(rest as P)} />
<AnnotationCallbacksContext.Provider value={callbacks ?? {}}>
<WrappedComponent {...(rest as P)} />
</AnnotationCallbacksContext.Provider>
</StoreProvider>
</RawIntlProvider>
);
Expand Down
Loading