diff --git a/src/@types/events.ts b/src/@types/events.ts index d37041c83..3abbbde16 100644 --- a/src/@types/events.ts +++ b/src/@types/events.ts @@ -26,6 +26,13 @@ enum Event { BOUNDING_BOX_HIGHLIGHT_NAVIGATE = 'bounding_box_highlight_navigate', } +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', @@ -33,4 +40,4 @@ enum LegacyEvent { SCALE = 'scaleannotations', } -export { Event, LegacyEvent }; +export { Event, LegacyEvent, SidebarEvent }; diff --git a/src/common/BaseAnnotator.ts b/src/common/BaseAnnotator.ts index 8e980ca96..3459e65a3 100644 --- a/src/common/BaseAnnotator.ts +++ b/src/common/BaseAnnotator.ts @@ -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, Token } 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'; @@ -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(); } @@ -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 { @@ -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(store.fetchAnnotationsAction()); // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/src/common/__tests__/BaseAnnotator-test.ts b/src/common/__tests__/BaseAnnotator-test.ts index 408bedd4a..b3200cf95 100644 --- a/src/common/__tests__/BaseAnnotator-test.ts +++ b/src/common/__tests__/BaseAnnotator-test.ts @@ -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'; @@ -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', () => { @@ -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()', () => { diff --git a/src/store/annotations/__tests__/reducer-test.ts b/src/store/annotations/__tests__/reducer-test.ts index 035d6274e..ac6f3f4ea 100644 --- a/src/store/annotations/__tests__/reducer-test.ts +++ b/src/store/annotations/__tests__/reducer-test.ts @@ -3,6 +3,10 @@ import {annotationState as state} from '../__mocks__/annotationsState'; import { Annotation, AnnotationDrawing, NewAnnotation, PathGroup, Reply } from '../../../@types'; import { APICollection } from '../../../api'; import { + applySidebarAnnotationUpdateAction, + applySidebarReplyCreateAction, + applySidebarReplyDeleteAction, + applySidebarReplyUpdateAction, createAnnotationAction, createReplyAction, deleteAnnotationAction, @@ -373,6 +377,214 @@ describe('store/annotations/reducer', () => { }); }); + describe('applySidebarAnnotationUpdateAction', () => { + test('should merge partial annotation into existing state without erasing other fields', () => { + const description = { message: 'original' } as unknown as Reply; + const stateWithDescription = { + ...state, + byId: { + ...state.byId, + test1: { ...state.byId.test1, description, status: 'open' } as unknown as Annotation, + }, + }; + + const newState = reducer( + stateWithDescription, + applySidebarAnnotationUpdateAction({ id: 'test1', status: 'resolved' }), + ); + + expect(newState.byId.test1).toMatchObject({ id: 'test1', description, status: 'resolved' }); + }); + + test('should ignore updates for annotations not in state', () => { + const newState = reducer( + state, + applySidebarAnnotationUpdateAction({ id: 'unknown' }), + ); + + expect(newState.byId).toEqual(state.byId); + }); + + test.each([['resolved' as const], ['open' as const]])( + 'should apply status=%s for resolve/unresolve flow', + annotationStatus => { + const newState = reducer(state, applySidebarAnnotationUpdateAction({ id: 'test1', status: annotationStatus })); + + expect(newState.byId.test1).toMatchObject({ status: annotationStatus }); + }, + ); + + test('should not erase existing fields when payload key value is undefined', () => { + const permissions = { can_delete: true, can_edit: true } as const; + const stateWithPermissions = { + ...state, + byId: { + ...state.byId, + test1: { ...state.byId.test1, permissions, status: 'open' } as unknown as Annotation, + }, + }; + + const newState = reducer( + stateWithPermissions, + applySidebarAnnotationUpdateAction({ + id: 'test1', + status: 'resolved', + permissions: undefined, + } as unknown as ReturnType['payload']), + ); + + expect(newState.byId.test1).toMatchObject({ permissions, status: 'resolved' }); + }); + }); + + describe('applySidebarReplyCreateAction', () => { + const reply = { + created_at: '2026-01-01T00:00:00Z', + created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' }, + id: 'reply-1', + message: 'A reply', + parent: { id: 'test1', type: 'annotation' }, + type: 'reply', + } as Reply; + + test('should append the reply when annotation has no replies yet', () => { + const newState = reducer(state, applySidebarReplyCreateAction({ annotationId: 'test1', reply })); + + expect(newState.byId.test1.replies).toHaveLength(1); + expect(newState.byId.test1.replies![0]).toEqual(reply); + }); + + test('should dedupe by reply.id when the same reply is applied twice', () => { + const stateWithReply = { + ...state, + byId: { + ...state.byId, + test1: { ...state.byId.test1, replies: [reply] } as unknown as Annotation, + }, + }; + + const newState = reducer( + stateWithReply, + applySidebarReplyCreateAction({ annotationId: 'test1', reply }), + ); + + expect(newState.byId.test1.replies).toHaveLength(1); + }); + + test('should ignore create for annotations not in state', () => { + const newState = reducer( + state, + applySidebarReplyCreateAction({ annotationId: 'unknown', reply }), + ); + + expect(newState.byId).toEqual(state.byId); + }); + }); + + describe('applySidebarReplyUpdateAction', () => { + const existingReply = { + created_at: '2026-01-01T00:00:00Z', + created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' }, + id: 'reply-1', + message: 'old', + parent: { id: 'test1', type: 'annotation' }, + type: 'reply', + } as Reply; + + test('should merge updated fields into the matching reply', () => { + const stateWithReply = { + ...state, + byId: { + ...state.byId, + test1: { ...state.byId.test1, replies: [existingReply] } as unknown as Annotation, + }, + }; + + const updatedReply = { ...existingReply, message: 'new' } as Reply; + const newState = reducer( + stateWithReply, + applySidebarReplyUpdateAction({ annotationId: 'test1', reply: updatedReply }), + ); + + expect(newState.byId.test1.replies![0]).toMatchObject({ id: 'reply-1', message: 'new' }); + }); + + test('should leave other replies untouched', () => { + const otherReply = { ...existingReply, id: 'reply-2', message: 'other' } as Reply; + const stateWithReplies = { + ...state, + byId: { + ...state.byId, + test1: { ...state.byId.test1, replies: [existingReply, otherReply] } as unknown as Annotation, + }, + }; + + const updatedReply = { ...existingReply, message: 'new' } as Reply; + const newState = reducer( + stateWithReplies, + applySidebarReplyUpdateAction({ annotationId: 'test1', reply: updatedReply }), + ); + + expect(newState.byId.test1.replies![1]).toEqual(otherReply); + }); + + test('should ignore update when annotation does not exist', () => { + const updatedReply = { ...existingReply, message: 'new' } as Reply; + const newState = reducer( + state, + applySidebarReplyUpdateAction({ annotationId: 'unknown', reply: updatedReply }), + ); + + expect(newState.byId).toEqual(state.byId); + }); + }); + + describe('applySidebarReplyDeleteAction', () => { + const replyA = { + created_at: '2026-01-01T00:00:00Z', + created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' }, + id: 'reply-1', + message: 'first', + parent: { id: 'test1', type: 'annotation' }, + type: 'reply', + } as Reply; + const replyB = { ...replyA, id: 'reply-2', message: 'second' } as Reply; + + test('should remove the targeted reply by id', () => { + const stateWithReplies = { + ...state, + byId: { + ...state.byId, + test1: { ...state.byId.test1, replies: [replyA, replyB] } as unknown as Annotation, + }, + }; + + const newState = reducer( + stateWithReplies, + applySidebarReplyDeleteAction({ annotationId: 'test1', replyId: 'reply-1' }), + ); + + expect(newState.byId.test1.replies).toEqual([replyB]); + }); + + test('should leave replies unchanged when id does not match', () => { + const stateWithReplies = { + ...state, + byId: { + ...state.byId, + test1: { ...state.byId.test1, replies: [replyA] } as unknown as Annotation, + }, + }; + + const newState = reducer( + stateWithReplies, + applySidebarReplyDeleteAction({ annotationId: 'test1', replyId: 'reply-other' }), + ); + + expect(newState.byId.test1.replies).toEqual([replyA]); + }); + }); + describe('setViewModeAction', () => { test('should clear activeId when switching to boundingBoxes mode', () => { const stateWithActiveAnnotation = { diff --git a/src/store/annotations/actions.ts b/src/store/annotations/actions.ts index a87e08aa2..7af817ea2 100644 --- a/src/store/annotations/actions.ts +++ b/src/store/annotations/actions.ts @@ -193,6 +193,23 @@ export const deleteReplyAction = createAsyncThunk< }, ); +export type SidebarAnnotationUpdatePayload = Partial & { id: string }; +export type SidebarReplyMutationPayload = { annotationId: string; reply: Reply }; +export type SidebarReplyDeletePayload = { annotationId: string; replyId: string }; + +export const applySidebarAnnotationUpdateAction = createAction( + 'APPLY_SIDEBAR_ANNOTATION_UPDATE', +); +export const applySidebarReplyCreateAction = createAction( + 'APPLY_SIDEBAR_REPLY_CREATE', +); +export const applySidebarReplyDeleteAction = createAction( + 'APPLY_SIDEBAR_REPLY_DELETE', +); +export const applySidebarReplyUpdateAction = createAction( + 'APPLY_SIDEBAR_REPLY_UPDATE', +); + export const removeAnnotationAction = createAction('REMOVE_ANNOTATION'); export const setActiveAnnotationIdAction = createAction('SET_ACTIVE_ANNOTATION_ID'); export const setIsInitialized = createAction('SET_IS_INITIALIZED'); diff --git a/src/store/annotations/reducer.ts b/src/store/annotations/reducer.ts index 5a868f6d8..0f3db6ba4 100644 --- a/src/store/annotations/reducer.ts +++ b/src/store/annotations/reducer.ts @@ -2,6 +2,10 @@ import { createReducer, combineReducers } from '@reduxjs/toolkit'; import { formatDrawing, isDrawing } from '../../drawing/drawingUtil'; import { AnnotationsState } from './types'; import { + applySidebarAnnotationUpdateAction, + applySidebarReplyCreateAction, + applySidebarReplyDeleteAction, + applySidebarReplyUpdateAction, createAnnotationAction, createReplyAction, deleteAnnotationAction, @@ -74,6 +78,34 @@ const annotationsById = createReducer({}, builder => annotation.replies = annotation.replies.filter(existing => existing.id !== replyId); } }) + .addCase(applySidebarAnnotationUpdateAction, (state, { payload }) => { + const existing = state[payload.id]; + if (!existing) return; + Object.entries(payload).forEach(([key, value]) => { + if (value !== undefined) { + (existing as Record)[key] = value; + } + }); + }) + .addCase(applySidebarReplyCreateAction, (state, { payload: { annotationId, reply } }) => { + const annotation = state[annotationId]; + if (!annotation) return; + const replies = annotation.replies ?? []; + if (replies.some(existing => existing.id === reply.id)) return; + annotation.replies = [...replies, reply]; + }) + .addCase(applySidebarReplyUpdateAction, (state, { payload: { annotationId, reply } }) => { + const annotation = state[annotationId]; + if (!annotation || !annotation.replies) return; + annotation.replies = annotation.replies.map(existing => + existing.id === reply.id ? { ...existing, ...reply } : existing, + ); + }) + .addCase(applySidebarReplyDeleteAction, (state, { payload: { annotationId, replyId } }) => { + const annotation = state[annotationId]; + if (!annotation || !annotation.replies) return; + annotation.replies = annotation.replies.filter(existing => existing.id !== replyId); + }) .addCase(fetchAnnotationsAction.fulfilled, (state, { payload }) => { payload.entries.forEach(annotation => { state[annotation.id] = isDrawing(annotation) ? formatDrawing(annotation) : annotation; diff --git a/src/store/eventing/__tests__/middleware-test.ts b/src/store/eventing/__tests__/middleware-test.ts index d858a4077..8f28ec15e 100644 --- a/src/store/eventing/__tests__/middleware-test.ts +++ b/src/store/eventing/__tests__/middleware-test.ts @@ -1,5 +1,9 @@ import getEventingMiddleware, { eventHandlers } from '../middleware'; import { + applySidebarAnnotationUpdateAction, + applySidebarReplyCreateAction, + applySidebarReplyDeleteAction, + applySidebarReplyUpdateAction, createAnnotationAction, createReplyAction, deleteAnnotationAction, @@ -51,5 +55,14 @@ describe('store/eventing/middleware', () => { expect(eventHandlers).toHaveProperty(thunk.pending.toString()); expect(eventHandlers).toHaveProperty(thunk.rejected.toString()); }); + + test.each([ + applySidebarAnnotationUpdateAction, + applySidebarReplyCreateAction, + applySidebarReplyDeleteAction, + applySidebarReplyUpdateAction, + ])('should NOT register sidebar inbound action $type in the eventing middleware', action => { + expect(eventHandlers).not.toHaveProperty(action.toString()); + }); }); });