From 0fb8c69524ec5c0a98e92d9eecbf29be7cba45ef Mon Sep 17 00:00:00 2001 From: Damian Lasecki Date: Thu, 18 Jun 2026 10:19:08 +0200 Subject: [PATCH 1/4] feat(hitl): decouple bboxes from confidence scores feat(hitl): flow fix feat(hitl): flow fix feat(hitl): flow fix feat(hitl): flow fix feat(hitl): flow fix --- package.json | 4 +- scripts/i18n.config.js | 1 + scripts/jest/jest.config.js | 2 +- src/api/Metadata.js | 72 ++++-- src/api/__tests__/Metadata.test.js | 240 ++++++++++++++++++ src/api/__tests__/utils.test.js | 39 +++ src/api/utils.js | 15 +- src/common/types/metadata.js | 1 + src/constants.js | 1 + .../MetadataInstanceEditor.tsx | 3 + .../MetadataSidebarRedesign.tsx | 19 +- .../__tests__/MetadataInstanceEditor.test.tsx | 8 + .../MetadataSidebarRedesign.test.tsx | 30 +++ ...MetadataSidebarUnsavedChangesGuard.test.ts | 10 +- .../useSidebarMetadataFetcher.test.tsx | 42 ++- .../useMetadataSidebarUnsavedChangesGuard.ts | 12 +- .../hooks/useSidebarMetadataFetcher.ts | 32 ++- yarn.lock | 13 +- 18 files changed, 487 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index 47f1f350fc..5b29e49150 100644 --- a/package.json +++ b/package.json @@ -138,8 +138,9 @@ "@box/frontend": "^11.0.1", "@box/item-icon": "^2.37.5", "@box/languages": "^1.0.0", - "@box/metadata-editor": "^1.69.6", + "@box/metadata-editor": "^1.73.4", "@box/metadata-filter": "^1.80.23", + "@box/metadata-template-browser": "^1.24.11", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/readable-time": "^1.43.5", @@ -307,6 +308,7 @@ "@box/item-icon": "^2.37.5", "@box/metadata-editor": "^1.69.6", "@box/metadata-filter": "^1.80.23", + "@box/metadata-template-browser": "^1.24.11", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/readable-time": "^1.43.5", diff --git a/scripts/i18n.config.js b/scripts/i18n.config.js index 821040bdb6..e64d25e6b6 100644 --- a/scripts/i18n.config.js +++ b/scripts/i18n.config.js @@ -10,6 +10,7 @@ module.exports = { '@box/item-icon', '@box/metadata-editor', '@box/metadata-filter', + '@box/metadata-template-browser', '@box/metadata-view', '@box/unified-share-modal', '@box/user-selector', diff --git a/scripts/jest/jest.config.js b/scripts/jest/jest.config.js index 4a7f6bb249..a8841a2c3a 100644 --- a/scripts/jest/jest.config.js +++ b/scripts/jest/jest.config.js @@ -28,6 +28,6 @@ module.exports = { testMatch: ['**/__tests__/**/*.test.+(js|jsx|ts|tsx)'], testPathIgnorePatterns: ['stories.test.js$', 'stories.test.tsx$', 'stories.test.d.ts'], transformIgnorePatterns: [ - 'node_modules/(?!(@box/activity-feed|@box/collaboration-popover|@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/content-field|@box/types|@box/box-item-type-selector|@box/unified-share-modal|@box/user-selector|@box/copy-input|@box/readable-time|@box/threaded-annotations|@box/uploads-manager)/)', + 'node_modules/(?!(@box/activity-feed|@box/collaboration-popover|@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/metadata-template-browser|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/content-field|@box/types|@box/box-item-type-selector|@box/unified-share-modal|@box/user-selector|@box/copy-input|@box/readable-time|@box/threaded-annotations|@box/uploads-manager)/)', ], }; diff --git a/src/api/Metadata.js b/src/api/Metadata.js index 4fa905f23c..fb1153b780 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -17,6 +17,7 @@ import uniqueId from 'lodash/uniqueId'; import { getBadItemError, getBadPermissionsError, isUserCorrectableError } from '../utils/error'; import { getTypedFileId, getTypedFolderId } from '../utils/file'; import { + checkIsExtractedProcessFieldValue, extractDetailedFieldValue, formatMetadataFieldValue, handleOnAbort, @@ -27,6 +28,7 @@ import { import File from './File'; import { AI_ACCEPTED_PROCESS, + AI_EXTRACTED_PROCESS, HEADER_CONTENT_TYPE, METADATA_SCOPE_ENTERPRISE, METADATA_SCOPE_GLOBAL, @@ -459,20 +461,20 @@ class Metadata extends File { * * @param {string} id - file id * @param {boolean} isMetadataRedesign - feature flag - * @param {boolean} isConfidenceScoreEnabled - whether to fetch detailed view with confidence scores + * @param {boolean} isBoundingBoxOrConfidenceScoreReviewEnabled - whether to fetch detailed view * @return {Object} array of metadata instances */ async getInstances( id: string, isMetadataRedesign: boolean = false, - isConfidenceScoreEnabled: boolean = false, + isBoundingBoxOrConfidenceScoreReviewEnabled: boolean = false, ): Promise> { this.errorCode = ERROR_CODE_FETCH_METADATA; const baseUrl = this.getMetadataUrl(id); const requestId = getTypedFileId(id); - if (isMetadataRedesign && isConfidenceScoreEnabled) { + if (isMetadataRedesign && isBoundingBoxOrConfidenceScoreReviewEnabled) { return this.getDetailedInstancesWithHydratedTaxonomy(baseUrl, requestId); } @@ -616,7 +618,7 @@ class Metadata extends File { template: MetadataTemplate, canEdit: boolean, isExternallyOwned: boolean = false, - isConfidenceScoreEnabled: boolean = false, + isBoundingBoxOrConfidenceScoreReviewEnabled: boolean = false, ): MetadataTemplateInstance { const fields: MetadataTemplateInstanceField[] = []; @@ -635,7 +637,7 @@ class Metadata extends File { value, }; - if (isConfidenceScoreEnabled) { + if (isBoundingBoxOrConfidenceScoreReviewEnabled) { const confidenceScore = mapDetailedFieldToConfidenceScore(rawValue); if (confidenceScore) { fieldEntry.confidenceScore = confidenceScore; @@ -645,6 +647,10 @@ class Metadata extends File { if (targetLocation) { fieldEntry.targetLocation = targetLocation; } + + if (checkIsExtractedProcessFieldValue(rawValue)) { + fieldEntry.isExtracted = true; + } } fields.push(fieldEntry); @@ -693,7 +699,7 @@ class Metadata extends File { enterpriseTemplates: Array, globalTemplates: Array, canEdit: boolean, - isConfidenceScoreEnabled: boolean = false, + isBoundingBoxOrConfidenceScoreReviewEnabled: boolean = false, ): Promise> { // Get all usable templates for metadata instances const templates: Array = [customPropertiesTemplate].concat( @@ -714,7 +720,7 @@ class Metadata extends File { result.template, canEdit, result.isExternallyOwned, - isConfidenceScoreEnabled, + isBoundingBoxOrConfidenceScoreReviewEnabled, ), ); } @@ -733,6 +739,7 @@ class Metadata extends File { * @param {boolean} hasMetadataFeature - metadata feature check * @param {Object} options - fetch options * @param {boolean} isMetadataRedesign - is Metadata Sidebar redesigned + * @param {boolean} isBoundingBoxOrConfidenceScoreReviewEnabled - whether to include bounding box or confidence score details in the payload * @return {Promise} */ async getMetadata( @@ -746,7 +753,7 @@ class Metadata extends File { hasMetadataFeature: boolean, options: RequestOptions = {}, isMetadataRedesign: boolean = false, - isConfidenceScoreEnabled: boolean = false, + isBoundingBoxOrConfidenceScoreReviewEnabled: boolean = false, ): Promise { const { id, permissions, is_externally_owned }: BoxItem = file; this.errorCode = ERROR_CODE_FETCH_METADATA; @@ -779,7 +786,7 @@ class Metadata extends File { try { const customPropertiesTemplate: MetadataTemplate = this.getCustomPropertiesTemplate(); const [instances, globalTemplates, enterpriseTemplates] = await Promise.all([ - this.getInstances(id, isMetadataRedesign, isConfidenceScoreEnabled), + this.getInstances(id, isMetadataRedesign, isBoundingBoxOrConfidenceScoreReviewEnabled), this.getTemplates(id, METADATA_SCOPE_GLOBAL), hasMetadataFeature ? this.getTemplates(id, METADATA_SCOPE_ENTERPRISE) : Promise.resolve([]), ]); @@ -795,7 +802,7 @@ class Metadata extends File { enterpriseTemplates, globalTemplates, !!permissions.can_upload, - isConfidenceScoreEnabled, + isBoundingBoxOrConfidenceScoreReviewEnabled, ) : []; const editors = !isMetadataRedesign @@ -1190,7 +1197,7 @@ class Metadata extends File { * @param {Object} template - Metadata Redesign template * @param {Function} successCallback - Success callback * @param {Function} errorCallback - Error callback - * @param {boolean} isConfidenceScoreEnabled - whether to include confidence score details in the payload + * @param {boolean} isBoundingBoxOrConfidenceScoreReviewEnabled - whether to include bounding box or confidence score details in the payload * @return {Promise} */ async createMetadataRedesign( @@ -1198,7 +1205,7 @@ class Metadata extends File { template: MetadataTemplateInstance, successCallback: Function, errorCallback: ElementsErrorCallback, - isConfidenceScoreEnabled: boolean = false, + isBoundingBoxOrConfidenceScoreReviewEnabled: boolean = false, ): Promise { this.errorCode = ERROR_CODE_CREATE_METADATA; if (!file || !template) { @@ -1248,26 +1255,37 @@ class Metadata extends File { return acc; }, {}); - if (isConfidenceScoreEnabled) { + if (isBoundingBoxOrConfidenceScoreReviewEnabled) { const details = template.fields.reduce((acc, field) => { - if (field.confidenceScore) { - const entry: { - confidenceScore: number, - confidenceLevel: string, - process?: string, - targetLocation?: string, - } = { - confidenceScore: field.confidenceScore.value, - confidenceLevel: field.confidenceScore.level, - }; - if (field.confidenceScore.isAccepted) { + const entry: { + confidenceScore?: number, + confidenceLevel?: string, + process?: string, + targetLocation?: string, + } = {}; + + if (field.isExtracted) { + entry.process = AI_EXTRACTED_PROCESS; + } + + const { confidenceScore } = field; + if (confidenceScore) { + entry.confidenceScore = confidenceScore.value; + entry.confidenceLevel = confidenceScore.level; + + if (confidenceScore.isAccepted) { entry.process = AI_ACCEPTED_PROCESS; } - if (field.targetLocation) { - entry.targetLocation = JSON.stringify(field.targetLocation); - } + } + + if (field.targetLocation) { + entry.targetLocation = JSON.stringify(field.targetLocation); + } + + if (Object.keys(entry).length > 0) { acc[field.key] = entry; } + return acc; }, {}); diff --git a/src/api/__tests__/Metadata.test.js b/src/api/__tests__/Metadata.test.js index 16402f4eeb..800bc4ed0c 100644 --- a/src/api/__tests__/Metadata.test.js +++ b/src/api/__tests__/Metadata.test.js @@ -425,6 +425,88 @@ describe('api/Metadata', () => { ]); }); + test('should set isExtracted when process is AI_EXTRACTED and isBoundingBoxOrConfidenceScoreReviewEnabled is true', () => { + const instance = { + $id: '321', + $template: '', + $canEdit: true, + testStringField: { + values: 'California', + details: { + process: 'AI_EXTRACTED', + }, + }, + }; + const template = { + displayName: 'Test template', + fields: [ + { description: 'Test', displayName: 'Test field', id: '1', key: 'testStringField', type: 'string' }, + ], + id: '123456', + templateKey: 'instance_from_template', + scope: 'enterprise', + }; + + const result = metadata.createTemplateInstance(instance, template, true, false, true); + + expect(result.fields[0].value).toBe('California'); + expect(result.fields[0].isExtracted).toBe(true); + }); + + test('should not set isExtracted when process is not AI_EXTRACTED', () => { + const instance = { + $id: '321', + $template: '', + $canEdit: true, + testStringField: { + values: 'California', + details: { + process: 'AI_ACCEPTED', + }, + }, + }; + const template = { + displayName: 'Test template', + fields: [ + { description: 'Test', displayName: 'Test field', id: '1', key: 'testStringField', type: 'string' }, + ], + id: '123456', + templateKey: 'instance_from_template', + scope: 'enterprise', + }; + + const result = metadata.createTemplateInstance(instance, template, true, false, true); + + expect(result.fields[0].isExtracted).toBeUndefined(); + }); + + test('should not set isExtracted when isBoundingBoxOrConfidenceScoreReviewEnabled is false', () => { + const instance = { + $id: '321', + $template: '', + $canEdit: true, + testStringField: { + values: 'California', + details: { + process: 'AI_EXTRACTED', + }, + }, + }; + const template = { + displayName: 'Test template', + fields: [ + { description: 'Test', displayName: 'Test field', id: '1', key: 'testStringField', type: 'string' }, + ], + id: '123456', + templateKey: 'instance_from_template', + scope: 'enterprise', + }; + + const result = metadata.createTemplateInstance(instance, template, true, false, false); + + expect(result.fields[0].isExtracted).toBeUndefined(); + }); + test('should extract values from detailed field in custom metadata (properties template)', () => { const instance = { $canEdit: true, @@ -3507,6 +3589,164 @@ describe('api/Metadata', () => { }, }); }); + + test('should include process as AI_EXTRACTED in $details when field has isExtracted', async () => { + const success = jest.fn(); + const error = jest.fn(); + const file = { + id: 'id', + permissions: { + can_upload: true, + }, + }; + const cache = new Cache(); + const template = { + scope: 'scope', + templateKey: 'templateKey', + fields: [ + { + key: 'invoiceDate', + type: 'string', + value: '2024-01-15', + isExtracted: true, + }, + ], + }; + + cache.set('metadata_id', { templateInstances: [] }); + + metadata.getMetadataUrl = jest.fn().mockReturnValueOnce('url'); + metadata.xhr.post = jest.fn().mockReturnValueOnce({ data: { $type: 'type' } }); + metadata.isDestroyed = jest.fn().mockReturnValueOnce(false); + metadata.getCache = jest.fn().mockReturnValueOnce(cache); + metadata.getMetadataCacheKey = jest.fn().mockReturnValueOnce('metadata_id'); + metadata.successHandler = jest.fn(); + metadata.errorHandler = jest.fn(); + + await metadata.createMetadataRedesign(file, template, success, error, true); + + expect(metadata.xhr.post).toHaveBeenCalledWith({ + url: 'url', + id: 'file_id', + data: { + invoiceDate: '2024-01-15', + $details: { + invoiceDate: { + process: 'AI_EXTRACTED', + }, + }, + }, + }); + }); + + test('should prefer AI_ACCEPTED over AI_EXTRACTED when field is both extracted and accepted', async () => { + const success = jest.fn(); + const error = jest.fn(); + const file = { + id: 'id', + permissions: { + can_upload: true, + }, + }; + const cache = new Cache(); + const template = { + scope: 'scope', + templateKey: 'templateKey', + fields: [ + { + key: 'invoiceDate', + type: 'string', + value: '2024-01-15', + isExtracted: true, + confidenceScore: { value: 0.9, level: 'HIGH', isAccepted: true }, + }, + ], + }; + + cache.set('metadata_id', { templateInstances: [] }); + + metadata.getMetadataUrl = jest.fn().mockReturnValueOnce('url'); + metadata.xhr.post = jest.fn().mockReturnValueOnce({ data: { $type: 'type' } }); + metadata.isDestroyed = jest.fn().mockReturnValueOnce(false); + metadata.getCache = jest.fn().mockReturnValueOnce(cache); + metadata.getMetadataCacheKey = jest.fn().mockReturnValueOnce('metadata_id'); + metadata.successHandler = jest.fn(); + metadata.errorHandler = jest.fn(); + + await metadata.createMetadataRedesign(file, template, success, error, true); + + expect(metadata.xhr.post).toHaveBeenCalledWith({ + url: 'url', + id: 'file_id', + data: { + invoiceDate: '2024-01-15', + $details: { + invoiceDate: { + confidenceScore: 0.9, + confidenceLevel: 'HIGH', + process: 'AI_ACCEPTED', + }, + }, + }, + }); + }); + + test('should include targetLocation in $details when field has targetLocation but no confidenceScore', async () => { + const success = jest.fn(); + const error = jest.fn(); + const file = { + id: 'id', + permissions: { + can_upload: true, + }, + }; + const cache = new Cache(); + const targetLocation = [ + { + itemId: '999', + page: 3, + text: 'Some text', + boundingBox: { left: 0.2, top: 0.3, right: 0.4, bottom: 0.5 }, + }, + ]; + const template = { + scope: 'scope', + templateKey: 'templateKey', + fields: [ + { + key: 'state', + type: 'string', + value: 'California', + targetLocation, + }, + ], + }; + + cache.set('metadata_id', { templateInstances: [] }); + + metadata.getMetadataUrl = jest.fn().mockReturnValueOnce('url'); + metadata.xhr.post = jest.fn().mockReturnValueOnce({ data: { $type: 'type' } }); + metadata.isDestroyed = jest.fn().mockReturnValueOnce(false); + metadata.getCache = jest.fn().mockReturnValueOnce(cache); + metadata.getMetadataCacheKey = jest.fn().mockReturnValueOnce('metadata_id'); + metadata.successHandler = jest.fn(); + metadata.errorHandler = jest.fn(); + + await metadata.createMetadataRedesign(file, template, success, error, true); + + expect(metadata.xhr.post).toHaveBeenCalledWith({ + url: 'url', + id: 'file_id', + data: { + state: 'California', + $details: { + state: { + targetLocation: JSON.stringify(targetLocation), + }, + }, + }, + }); + }); }); describe('deleteMetadata()', () => { diff --git a/src/api/__tests__/utils.test.js b/src/api/__tests__/utils.test.js index ea1b76d899..f8c48dba20 100644 --- a/src/api/__tests__/utils.test.js +++ b/src/api/__tests__/utils.test.js @@ -1,6 +1,7 @@ import Xhr from '../../utils/Xhr'; import { getAbortError } from '../../utils/error'; import { + checkIsExtractedProcessFieldValue, extractDetailedFieldValue, formatComment, formatMetadataFieldValue, @@ -225,6 +226,44 @@ describe('api/utils', () => { }); }); + describe('checkIsExtractedProcessFieldValue()', () => { + test('should return true when process is AI_EXTRACTED', () => { + const fieldValue = { + values: 'California', + details: { process: 'AI_EXTRACTED' }, + }; + expect(checkIsExtractedProcessFieldValue(fieldValue)).toBe(true); + }); + + test('should return false when process is not AI_EXTRACTED', () => { + const fieldValue = { + values: 'California', + details: { process: 'AI_ACCEPTED' }, + }; + expect(checkIsExtractedProcessFieldValue(fieldValue)).toBe(false); + }); + + test('should return false when process is missing', () => { + const fieldValue = { + values: 'California', + details: { confidenceScore: 0.9 }, + }; + expect(checkIsExtractedProcessFieldValue(fieldValue)).toBe(false); + }); + + test('should return false when details is missing', () => { + expect(checkIsExtractedProcessFieldValue({ values: 'California' })).toBe(false); + }); + + test('should return false when fieldValue is not a detailed field value', () => { + expect(checkIsExtractedProcessFieldValue('California')).toBe(false); + }); + + test('should return false for null', () => { + expect(checkIsExtractedProcessFieldValue(null)).toBe(false); + }); + }); + describe('formatMetadataFieldValue()', () => { test('should format string field value', async () => { const stringField = { diff --git a/src/api/utils.js b/src/api/utils.js index 3f3eaa5cf8..f4c543b98d 100644 --- a/src/api/utils.js +++ b/src/api/utils.js @@ -17,6 +17,8 @@ import type { } from '../common/types/metadata'; import { FIELD_TYPE_TAXONOMY } from '../features/metadata-instance-fields/constants'; +import { AI_EXTRACTED_PROCESS, AI_ACCEPTED_PROCESS } from '../constants'; + /** * Formats comment data (including replies) for use in components. * @@ -70,7 +72,7 @@ const mapDetailedFieldToConfidenceScore = (fieldValue: any): ?MetadataConfidence return { value: details.confidenceScore, level: details.confidenceLevel, - isAccepted: details.process === 'AI_ACCEPTED', + isAccepted: details.process === AI_ACCEPTED_PROCESS, }; }; @@ -95,6 +97,16 @@ const parseTargetLocation = (fieldValue: any): ?Array { + if (!isDetailedFieldValue(fieldValue)) { + return false; + } + + const { details } = ((fieldValue: any): MetadataDetailedFieldValue); + + return details != null && typeof details.process === 'string' && details.process === AI_EXTRACTED_PROCESS; +}; + const mergeDetailedAndHydratedInstances = ( detailedEntries: Array, hydratedEntries: Array, @@ -135,6 +147,7 @@ const handleOnAbort = (xhr: Xhr) => { }; export { + checkIsExtractedProcessFieldValue, extractDetailedFieldValue, formatComment, formatMetadataFieldValue, diff --git a/src/common/types/metadata.js b/src/common/types/metadata.js index ae057034f8..ba99410e39 100644 --- a/src/common/types/metadata.js +++ b/src/common/types/metadata.js @@ -194,6 +194,7 @@ type MetadataDetailedFieldValue = { type MetadataTemplateInstanceField = { confidenceScore?: MetadataConfidenceScoreData, + isExtracted?: boolean, description?: string, displayName?: string, hidden?: boolean, diff --git a/src/constants.js b/src/constants.js index ac345919ba..e70ebc5ba5 100644 --- a/src/constants.js +++ b/src/constants.js @@ -451,6 +451,7 @@ export const SIDEBAR_VIEW_ACTIVITY: 'activity' = 'activity'; export const SIDEBAR_VIEW_VERSIONS: 'versions' = 'versions'; export const SIDEBAR_VIEW_DOCGEN: 'docgen' = 'docgen'; export const AI_ACCEPTED_PROCESS: 'AI_ACCEPTED' = 'AI_ACCEPTED'; +export const AI_EXTRACTED_PROCESS: 'AI_EXTRACTED' = 'AI_EXTRACTED'; /* ------------------ HTTP Requests ---------------------- */ export const HTTP_GET: 'GET' = 'GET'; diff --git a/src/elements/content-sidebar/MetadataInstanceEditor.tsx b/src/elements/content-sidebar/MetadataInstanceEditor.tsx index 4fe986fb8b..5336e50d45 100644 --- a/src/elements/content-sidebar/MetadataInstanceEditor.tsx +++ b/src/elements/content-sidebar/MetadataInstanceEditor.tsx @@ -38,6 +38,7 @@ export interface MetadataInstanceEditorProps { onSelectMetadataField?: (field: MetadataTemplateField | null) => void; selectedMetadataFieldId?: string | null; trackEvent?: (eventName: string, data?: Record) => void; + isBoundingBoxEnabled?: boolean; } const MetadataInstanceEditor: React.FC = ({ @@ -61,6 +62,7 @@ const MetadataInstanceEditor: React.FC = ({ template, isAdvancedExtractAgentEnabled = false, isConfidenceScoreReviewEnabled = false, + isBoundingBoxEnabled = false, onSelectMetadataField, selectedMetadataFieldId, trackEvent, @@ -91,6 +93,7 @@ const MetadataInstanceEditor: React.FC = ({ taxonomyOptionsFetcher={taxonomyOptionsFetcher} isAdvancedExtractAgentEnabled={isAdvancedExtractAgentEnabled} isConfidenceScoreReviewEnabled={isConfidenceScoreReviewEnabled} + isBoundingBoxEnabled={isBoundingBoxEnabled} onSelectMetadataField={onSelectMetadataField} selectedMetadataFieldId={selectedMetadataFieldId} customRef={customRef} diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index 7672d1a90a..e52a04bced 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -120,6 +120,9 @@ function MetadataSidebarRedesign({ 'metadata.deleteConfirmationModalCheckbox.enabled', ); const isConfidenceScoreReviewEnabled: boolean = useFeatureEnabled('metadata.confidenceScore.enabled'); + const isBoundingBoxEnabled = useFeatureEnabled('metadata.boundingBox.enabled'); + + const isBoundingBoxOrConfidenceScoreReviewEnabled = isBoundingBoxEnabled || isConfidenceScoreReviewEnabled; const { clearExtractError, @@ -133,7 +136,15 @@ function MetadataSidebarRedesign({ errorMessage, status, templateInstances, - } = useSidebarMetadataFetcher(api, fileId, onError, onSuccess, isFeatureEnabled, isConfidenceScoreReviewEnabled); + } = useSidebarMetadataFetcher( + api, + fileId, + onError, + onSuccess, + isFeatureEnabled, + isConfidenceScoreReviewEnabled, + isBoundingBoxEnabled, + ); const isSessionInitiated = useRef(false); const [isLargeFile, setIsLargeFile] = useState(false); @@ -151,7 +162,7 @@ function MetadataSidebarRedesign({ editingTemplate, fileId, history, - isConfidenceScoreReviewEnabled, + isBoundingBoxOrConfidenceScoreReviewEnabled, onWarningModalClose, onEditingStateChange, setEditingTemplate, @@ -221,7 +232,7 @@ function MetadataSidebarRedesign({ }; const handleDiscardUnsavedChanges = () => { - if (pendingNavLocation && isConfidenceScoreReviewEnabled) { + if (pendingNavLocation && isBoundingBoxOrConfidenceScoreReviewEnabled) { unblockRouterHistory(); setEditingTemplate(null); history.push(pendingNavLocation); @@ -368,6 +379,7 @@ function MetadataSidebarRedesign({ template={editingTemplate} isAdvancedExtractAgentEnabled={isAdvancedExtractAgentEnabled} isConfidenceScoreReviewEnabled={isConfidenceScoreReviewEnabled} + isBoundingBoxEnabled={isBoundingBoxEnabled} onSelectMetadataField={handleSelectMetadataField} selectedMetadataFieldId={selectedMetadataFieldId} trackEvent={trackEvent} @@ -389,6 +401,7 @@ function MetadataSidebarRedesign({ templateInstances={templateInstancesList} taxonomyNodeFetcher={taxonomyNodeFetcher} isConfidenceScoreReviewEnabled={isConfidenceScoreReviewEnabled} + isBoundingBoxEnabled={isBoundingBoxEnabled} trackEvent={trackEvent} /> )} diff --git a/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx index 21288fa39a..8865b0eebe 100644 --- a/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx @@ -171,6 +171,14 @@ describe('MetadataInstanceEditor', () => { expect(templateHeader).toBeInTheDocument(); }); + test('should render MetadataInstanceForm with isBoundingBoxEnabled prop', () => { + const props = { ...defaultProps, isBoundingBoxEnabled: true }; + renderWithAutofill(); + + const templateHeader = screen.getByText(mockMetadataTemplateInstance.displayName); + expect(templateHeader).toBeInTheDocument(); + }); + test('should render without onToggleReviewFilter prop (optional)', () => { const props = { ...defaultProps, onToggleReviewFilter: undefined }; expect(() => renderWithAutofill()).not.toThrow(); diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx index 49f1d18ac1..c0eb7b4e25 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx @@ -526,6 +526,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), // onSuccess expect.anything(), // isFeatureEnabled true, // isConfidenceScoreReviewEnabled + false, // isBoundingBoxEnabled ); }); @@ -539,6 +540,35 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect.anything(), expect.anything(), false, + false, + ); + }); + + test('should pass isBoundingBoxEnabled to useSidebarMetadataFetcher', () => { + renderComponent({}, { 'metadata.boundingBox.enabled': true }); + + expect(mockUseSidebarMetadataFetcher).toHaveBeenCalledWith( + expect.anything(), // api + expect.anything(), // fileId + expect.anything(), // onError + expect.anything(), // onSuccess + expect.anything(), // isFeatureEnabled + false, // isConfidenceScoreReviewEnabled + true, // isBoundingBoxEnabled + ); + }); + + test('should pass isBoundingBoxEnabled=false when feature flag is off', () => { + renderComponent({}, { 'metadata.boundingBox.enabled': false }); + + expect(mockUseSidebarMetadataFetcher).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + false, + false, ); }); diff --git a/src/elements/content-sidebar/__tests__/useMetadataSidebarUnsavedChangesGuard.test.ts b/src/elements/content-sidebar/__tests__/useMetadataSidebarUnsavedChangesGuard.test.ts index 3b58ff218d..42560ce236 100644 --- a/src/elements/content-sidebar/__tests__/useMetadataSidebarUnsavedChangesGuard.test.ts +++ b/src/elements/content-sidebar/__tests__/useMetadataSidebarUnsavedChangesGuard.test.ts @@ -33,7 +33,7 @@ const defaultProps: Params = { editingTemplate: null, fileId: 'file-1', history: mockHistory, - isConfidenceScoreReviewEnabled: true, + isBoundingBoxOrConfidenceScoreReviewEnabled: true, onEditingStateChange: mockOnEditingStateChange, setEditingTemplate: mockSetEditingTemplate, setIsUnsavedChangesModalOpen: mockSetIsUnsavedChangesModalOpen, @@ -88,7 +88,7 @@ describe('useMetadataSidebarUnsavedChangesGuard', () => { const { rerender } = setup(); jest.clearAllMocks(); - rerender({ isConfidenceScoreReviewEnabled: false }); + rerender({ isBoundingBoxOrConfidenceScoreReviewEnabled: false }); expect(mockSetEditingTemplate).not.toHaveBeenCalled(); }); @@ -130,8 +130,8 @@ describe('useMetadataSidebarUnsavedChangesGuard', () => { expect(mockBlock).not.toHaveBeenCalled(); }); - test('should NOT call history.block when isConfidenceScoreReviewEnabled is false', () => { - setup({ editingTemplate: mockTemplate, isConfidenceScoreReviewEnabled: false }); + test('should NOT call history.block when isBoundingBoxOrConfidenceScoreReviewEnabled is false', () => { + setup({ editingTemplate: mockTemplate, isBoundingBoxOrConfidenceScoreReviewEnabled: false }); expect(mockBlock).not.toHaveBeenCalled(); }); @@ -228,7 +228,7 @@ describe('useMetadataSidebarUnsavedChangesGuard', () => { test('should NOT call history.replace when feature flag is off', () => { const { result } = setup({ editingTemplate: mockTemplate, - isConfidenceScoreReviewEnabled: false, + isBoundingBoxOrConfidenceScoreReviewEnabled: false, }); act(() => { diff --git a/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx b/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx index fd9bc31d26..24ab89d7aa 100644 --- a/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx +++ b/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx @@ -166,7 +166,7 @@ describe('useSidebarMetadataFetcher', () => { const onSuccessMock = jest.fn(); const isFeatureEnabledMock = true; - const setupHook = (fileId = '123', isConfidenceScoreEnabled = false) => + const setupHook = (fileId = '123', isConfidenceScoreEnabled = false, isBoundingBoxEnabled = false) => renderHook(() => useSidebarMetadataFetcher( api, @@ -175,6 +175,7 @@ describe('useSidebarMetadataFetcher', () => { onSuccessMock, isFeatureEnabledMock, isConfidenceScoreEnabled, + isBoundingBoxEnabled, ), ); @@ -419,6 +420,22 @@ describe('useSidebarMetadataFetcher', () => { ); }); + test('should pass true to getMetadata when only isBoundingBoxEnabled is true', async () => { + const { result } = setupHook('123', false, true); + + await waitFor(() => expect(result.current.status).toBe(STATUS.SUCCESS)); + + expect(mockAPI.getMetadata).toHaveBeenCalledWith( + mockFile, + expect.any(Function), + expect.any(Function), + isFeatureEnabledMock, + { refreshCache: true }, + true, + true, + ); + }); + describe('extractSuggestions', () => { test('should extract suggestions successfully', async () => { mockAPI.extractStructured.mockResolvedValue({ @@ -641,6 +658,29 @@ describe('useSidebarMetadataFetcher', () => { ); }); + test('should include only include_reference when only isBoundingBoxEnabled is true', async () => { + mockAPI.extractStructured.mockResolvedValue({ + answer: { field1: 'value1' }, + created_at: '2026-03-27T08:10:14.106-07:00', + completion_reason: 'done', + }); + + const { result } = setupHook('123', false, true); + + await result.current.extractSuggestions('templateKey', 'global'); + + expect(mockAPI.extractStructured).toHaveBeenCalledWith({ + items: [{ id: mockFile.id, type: mockFile.type }], + metadata_template: { template_key: 'templateKey', scope: 'global', type: 'metadata_template' }, + include_reference: true, + }); + expect(mockAPI.extractStructured).toHaveBeenCalledWith( + expect.not.objectContaining({ + include_confidence_score: expect.anything(), + }), + ); + }); + test('should parse response with confidence scores', async () => { mockAPI.extractStructured.mockResolvedValue({ answer: { field1: 'value1', field2: 'value2' }, diff --git a/src/elements/content-sidebar/hooks/useMetadataSidebarUnsavedChangesGuard.ts b/src/elements/content-sidebar/hooks/useMetadataSidebarUnsavedChangesGuard.ts index 9147c93c04..9e1a38b3d2 100644 --- a/src/elements/content-sidebar/hooks/useMetadataSidebarUnsavedChangesGuard.ts +++ b/src/elements/content-sidebar/hooks/useMetadataSidebarUnsavedChangesGuard.ts @@ -7,7 +7,7 @@ interface Params { editingTemplate: MetadataTemplateInstance | null; fileId: string; history: History; - isConfidenceScoreReviewEnabled: boolean; + isBoundingBoxOrConfidenceScoreReviewEnabled: boolean; onEditingStateChange?: (isEditing: boolean) => void; setEditingTemplate: (t: MetadataTemplateInstance | null) => void; setIsUnsavedChangesModalOpen: (open: boolean) => void; @@ -27,7 +27,7 @@ export default function useMetadataSidebarUnsavedChangesGuard({ editingTemplate, fileId, history, - isConfidenceScoreReviewEnabled, + isBoundingBoxOrConfidenceScoreReviewEnabled, onEditingStateChange, setEditingTemplate, setIsUnsavedChangesModalOpen, @@ -64,7 +64,7 @@ export default function useMetadataSidebarUnsavedChangesGuard({ const handleUnsavedChangesModalOpen = useCallback( (isOpen: boolean) => { setIsUnsavedChangesModalOpen(isOpen); - if (isOpen || !isConfidenceScoreReviewEnabled) { + if (isOpen || !isBoundingBoxOrConfidenceScoreReviewEnabled) { return; } @@ -78,7 +78,7 @@ export default function useMetadataSidebarUnsavedChangesGuard({ }, [ setIsUnsavedChangesModalOpen, - isConfidenceScoreReviewEnabled, + isBoundingBoxOrConfidenceScoreReviewEnabled, pendingNavLocation, history, onWarningModalClose, @@ -94,14 +94,14 @@ export default function useMetadataSidebarUnsavedChangesGuard({ }, [registerOpenWarningModalCallback, handleUnsavedChangesModalOpen]); useEffect(() => { - if (!editingTemplate || !isConfidenceScoreReviewEnabled) { + if (!editingTemplate || !isBoundingBoxOrConfidenceScoreReviewEnabled) { return undefined; } blockRouterHistory(); return () => unblockRouterHistory(); - }, [editingTemplate, isConfidenceScoreReviewEnabled, blockRouterHistory, unblockRouterHistory]); + }, [editingTemplate, isBoundingBoxOrConfidenceScoreReviewEnabled, blockRouterHistory, unblockRouterHistory]); return { handleUnsavedChangesModalOpen, diff --git a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts index 54b6fa3168..577b9f2d7b 100644 --- a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts +++ b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts @@ -71,6 +71,7 @@ function useSidebarMetadataFetcher( onSuccess: SuccessContextProps['onSuccess'], isFeatureEnabled: ExternalProps['isFeatureEnabled'], isConfidenceScoreEnabled: boolean = false, + isBoundindBoxEnabled: boolean = false, ): DataFetcher { const [status, setStatus] = React.useState(STATUS.IDLE); const [file, setFile] = React.useState(null); @@ -79,6 +80,8 @@ function useSidebarMetadataFetcher( const [templateInstances, setTemplateInstances] = React.useState>([]); const [extractErrorCode, setExtractErrorCode] = React.useState(null); + const isBoundingBoxOrConfidenceScoreReviewEnabled = isBoundindBoxEnabled || isConfidenceScoreEnabled; + const onApiError = React.useCallback( (error: ElementsXhrError, code: string, message: MessageDescriptor) => { const { status: errorStatus } = error; @@ -127,10 +130,16 @@ function useSidebarMetadataFetcher( isFeatureEnabled, { refreshCache: true }, true, - isConfidenceScoreEnabled, + isBoundingBoxOrConfidenceScoreReviewEnabled, ); }, - [api, fetchMetadataErrorCallback, fetchMetadataSuccessCallback, isFeatureEnabled, isConfidenceScoreEnabled], + [ + api, + fetchMetadataErrorCallback, + fetchMetadataSuccessCallback, + isFeatureEnabled, + isBoundingBoxOrConfidenceScoreReviewEnabled, + ], ); const fetchFileSuccessCallback = React.useCallback( @@ -190,10 +199,10 @@ function useSidebarMetadataFetcher( }, (error: ElementsXhrError, code: string) => onApiError(error, code, messages.sidebarMetadataEditingErrorContent), - isConfidenceScoreEnabled, + isBoundingBoxOrConfidenceScoreReviewEnabled, ); }, - [api, file, onApiError, onSuccess, isConfidenceScoreEnabled], + [api, file, onApiError, onSuccess, isBoundingBoxOrConfidenceScoreReviewEnabled], ); const handleUpdateMetadataInstance = React.useCallback( @@ -225,14 +234,21 @@ function useSidebarMetadataFetcher( setExtractErrorCode(null); let response = null; const customAiAgent = agentId ? { ai_agent: { type: 'ai_agent_id', id: agentId } } : {}; - const confidenceScoreParams = isConfidenceScoreEnabled - ? { include_confidence_score: true, include_reference: true } - : {}; + + const confidenceScoreParams = isConfidenceScoreEnabled ? { include_confidence_score: true } : {}; + + // Additive gating during the metadata_bounding_box rollout: fetch bounding boxes from AI extract API when + // when either the confidence-score coupling or the new bounding-box flag is on. + // TODO: drop the isBoundingBoxOrConfidenceScoreReviewEnabled fallback so + // fetching bounding boxes depends solely on `isBoundingBoxEnabled`. + const boundindBoxParams = isBoundingBoxOrConfidenceScoreReviewEnabled ? { include_reference: true } : {}; + const requestBody: AiExtractStructured = { items: [{ id: file.id, type: file.type }], metadata_template: { template_key: templateKey, scope, type: 'metadata_template' }, ...customAiAgent, ...confidenceScoreParams, + ...boundindBoxParams, }; try { @@ -304,7 +320,7 @@ function useSidebarMetadataFetcher( return result; }); }, - [api, file, isConfidenceScoreEnabled, onError, templates], + [api, file, isConfidenceScoreEnabled, isBoundingBoxOrConfidenceScoreReviewEnabled, onError, templates], ); React.useEffect(() => { diff --git a/yarn.lock b/yarn.lock index dd4b698dd8..225193d010 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1179,16 +1179,21 @@ resolved "https://registry.yarnpkg.com/@box/languages/-/languages-1.1.2.tgz#cd4266b3da62da18560d881e10b429653186be29" integrity sha512-d64TGosx+KRmrLZj4CIyLp42LUiEbgBJ8n8cviMQwTJmfU0g+UwZqLjmQZR1j+Q9D64yV4xHzY9K1t5nInWWeQ== -"@box/metadata-editor@^1.69.6": - version "1.69.6" - resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-1.69.6.tgz#56a0f185e4e5b64807b32e8c155812bead8b6598" - integrity sha512-pVQD0FyabgpIchYBpVbi44iMz+wzRVW5YeP0FslbfHMpCGJL9lABWWN0aBp68CXRXJbSZXr/4YQUQJ0YsmRmoA== +"@box/metadata-editor@^1.73.4": + version "1.73.11" + resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-1.73.11.tgz#ab80b67d3b6257bf7019a5711d803eb277993796" + integrity sha512-eiP+im6cDvDsYqQClUpt8Xc25yweJ0P6779YC7U/i4fsl21w6+FqddjtoXBtinfVqwXgo2Jelbl4wqgK/r8CYA== "@box/metadata-filter@^1.80.23": version "1.80.23" resolved "https://registry.yarnpkg.com/@box/metadata-filter/-/metadata-filter-1.80.23.tgz#4eefe51151f71cc96081d5e2d036be18f1f42008" integrity sha512-6G5vgumSbUGk0r9SgCMol9HUW/9CkiHwDL5F8wH4EeRlF75xnxLGw7Q83BMMQfpJv+kkWhjJ0C/O9u0a07nBuQ== +"@box/metadata-template-browser@^1.24.11": + version "1.24.13" + resolved "https://registry.yarnpkg.com/@box/metadata-template-browser/-/metadata-template-browser-1.24.13.tgz#95f5490bf746a3c0e1629e8ba253c60295378764" + integrity sha512-P7Njbhom5dt2tLL1LCZ3jpCK76oG0mXVn3tECCpfcw2cAthZjprzPl1tgKcyWEA1mAwuiNGKl1AcQMiIXwLJ1A== + "@box/metadata-view@^1.53.26": version "1.53.26" resolved "https://registry.yarnpkg.com/@box/metadata-view/-/metadata-view-1.53.26.tgz#fdb3ebb1e079ba899c491c5bc9971bc4402f63e2" From 49a758d40f63b487a01b26d0b5f2db7727d37a94 Mon Sep 17 00:00:00 2001 From: Damian Lasecki Date: Thu, 18 Jun 2026 12:44:42 +0200 Subject: [PATCH 2/4] feat(hitl): update peer dependencies --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5b29e49150..86f48f6e69 100644 --- a/package.json +++ b/package.json @@ -306,7 +306,7 @@ "@box/content-field": "^1.40.23", "@box/copy-input": "^1.42.16", "@box/item-icon": "^2.37.5", - "@box/metadata-editor": "^1.69.6", + "@box/metadata-editor": "^1.73.4", "@box/metadata-filter": "^1.80.23", "@box/metadata-template-browser": "^1.24.11", "@box/metadata-view": "^1.53.26", From 4ffbdd03d431d063e4cb1d8df7cbe1dbe8668278 Mon Sep 17 00:00:00 2001 From: Damian Lasecki Date: Thu, 18 Jun 2026 12:46:03 +0200 Subject: [PATCH 3/4] feat(hitl): fix typo --- .../content-sidebar/hooks/useSidebarMetadataFetcher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts index 577b9f2d7b..ca98441f2f 100644 --- a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts +++ b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts @@ -241,14 +241,14 @@ function useSidebarMetadataFetcher( // when either the confidence-score coupling or the new bounding-box flag is on. // TODO: drop the isBoundingBoxOrConfidenceScoreReviewEnabled fallback so // fetching bounding boxes depends solely on `isBoundingBoxEnabled`. - const boundindBoxParams = isBoundingBoxOrConfidenceScoreReviewEnabled ? { include_reference: true } : {}; + const boundingBoxParams = isBoundingBoxOrConfidenceScoreReviewEnabled ? { include_reference: true } : {}; const requestBody: AiExtractStructured = { items: [{ id: file.id, type: file.type }], metadata_template: { template_key: templateKey, scope, type: 'metadata_template' }, ...customAiAgent, ...confidenceScoreParams, - ...boundindBoxParams, + ...boundingBoxParams, }; try { From f110c55101558f50175c390e5a5c02a5dc1b55f7 Mon Sep 17 00:00:00 2001 From: Damian Lasecki Date: Thu, 18 Jun 2026 18:56:29 +0200 Subject: [PATCH 4/4] feat(hitl): fix typo --- .../content-sidebar/hooks/useSidebarMetadataFetcher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts index ca98441f2f..0b06603e3c 100644 --- a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts +++ b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts @@ -71,7 +71,7 @@ function useSidebarMetadataFetcher( onSuccess: SuccessContextProps['onSuccess'], isFeatureEnabled: ExternalProps['isFeatureEnabled'], isConfidenceScoreEnabled: boolean = false, - isBoundindBoxEnabled: boolean = false, + isBoundingBoxEnabled: boolean = false, ): DataFetcher { const [status, setStatus] = React.useState(STATUS.IDLE); const [file, setFile] = React.useState(null); @@ -80,7 +80,7 @@ function useSidebarMetadataFetcher( const [templateInstances, setTemplateInstances] = React.useState>([]); const [extractErrorCode, setExtractErrorCode] = React.useState(null); - const isBoundingBoxOrConfidenceScoreReviewEnabled = isBoundindBoxEnabled || isConfidenceScoreEnabled; + const isBoundingBoxOrConfidenceScoreReviewEnabled = isBoundingBoxEnabled || isConfidenceScoreEnabled; const onApiError = React.useCallback( (error: ElementsXhrError, code: string, message: MessageDescriptor) => {