From ca8c5cb5172298c1309a39054fe88fcafce71876 Mon Sep 17 00:00:00 2001 From: Damian Lasecki Date: Wed, 15 Apr 2026 09:02:23 +0200 Subject: [PATCH] feat(metadata-editor): merge hydrated and detailed view results --- src/api/Metadata.js | 41 ++++++++- src/api/__tests__/Metadata.test.js | 48 ++++++++-- src/api/__tests__/utils.test.js | 139 +++++++++++++++++++++++++++++ src/api/utils.js | 35 ++++++++ 4 files changed, 253 insertions(+), 10 deletions(-) diff --git a/src/api/Metadata.js b/src/api/Metadata.js index a3272f460e..50c791a9f1 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -21,6 +21,7 @@ import { formatMetadataFieldValue, handleOnAbort, mapDetailedFieldToConfidenceScore, + mergeDetailedAndHydratedInstances, parseTargetLocation, } from './utils'; import File from './File'; @@ -391,6 +392,35 @@ class Metadata extends File { return response; } + /** + * Fetches both detailed and hydrated metadata views and merges them so that + * the result is in detailed format but with hydrated taxonomy values. + * + * @param {string} baseUrl - metadata API base URL + * @param {string} requestId - typed file id + * @return {Array} merged metadata instances + */ + async getDetailedInstancesWithHydratedTaxonomy( + baseUrl: string, + requestId: string, + ): Promise> { + try { + const [detailedResponse, hydratedResponse] = await Promise.all([ + this.xhr.get({ url: `${baseUrl}?view=detailed`, id: requestId }), + this.xhr.get({ url: `${baseUrl}?view=hydrated`, id: requestId }), + ]); + const detailedEntries = getProp(detailedResponse, 'data.entries', []); + const hydratedEntries = getProp(hydratedResponse, 'data.entries', []); + return mergeDetailedAndHydratedInstances(detailedEntries, hydratedEntries); + } catch (e) { + const { status } = e; + if (isUserCorrectableError(status)) { + throw e; + } + } + return []; + } + /** * Gets metadata instances for a Box file * @@ -407,14 +437,19 @@ class Metadata extends File { this.errorCode = ERROR_CODE_FETCH_METADATA; const baseUrl = this.getMetadataUrl(id); - const view = isConfidenceScoreEnabled ? 'detailed' : 'hydrated'; - const url = isMetadataRedesign ? `${baseUrl}?view=${view}` : baseUrl; + const requestId = getTypedFileId(id); + + if (isMetadataRedesign && isConfidenceScoreEnabled) { + return this.getDetailedInstancesWithHydratedTaxonomy(baseUrl, requestId); + } + + const url = isMetadataRedesign ? `${baseUrl}?view=hydrated` : baseUrl; let instances = {}; try { instances = await this.xhr.get({ url, - id: getTypedFileId(id), + id: requestId, }); } catch (e) { const { status } = e; diff --git a/src/api/__tests__/Metadata.test.js b/src/api/__tests__/Metadata.test.js index b130ef9887..73d50f6677 100644 --- a/src/api/__tests__/Metadata.test.js +++ b/src/api/__tests__/Metadata.test.js @@ -713,18 +713,52 @@ describe('api/Metadata', () => { }); }); - test('should apply detailed view query string param when isMetadataRedesign and isConfidenceScoreEnabled are true', async () => { + test('should make both detailed and hydrated calls when isMetadataRedesign and isConfidenceScoreEnabled are true', async () => { metadata.getMetadataUrl = jest.fn().mockReturnValueOnce('metadata_url'); - metadata.xhr.get = jest.fn().mockReturnValueOnce({ - data: { - entries: [], - }, - }); - await metadata.getInstances('id', true, true); + metadata.xhr.get = jest + .fn() + .mockResolvedValueOnce({ + data: { + entries: [ + { + $id: 'inst-1', + region: { + values: ['uuid-1'], + details: { updatedAt: 1000, updatedBy: 'user1', updatedAppId: 'app1' }, + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ + data: { + entries: [ + { + $id: 'inst-1', + region: [{ id: 'uuid-1', displayName: 'Japan', level: '1', nodePath: [] }], + }, + ], + }, + }); + const result = await metadata.getInstances('id', true, true); + expect(metadata.xhr.get).toHaveBeenCalledTimes(2); expect(metadata.xhr.get).toHaveBeenCalledWith({ url: 'metadata_url?view=detailed', id: 'file_id', }); + expect(metadata.xhr.get).toHaveBeenCalledWith({ + url: 'metadata_url?view=hydrated', + id: 'file_id', + }); + expect(result).toEqual([ + { + $id: 'inst-1', + region: { + values: [{ id: 'uuid-1', displayName: 'Japan', level: '1', nodePath: [] }], + details: { updatedAt: 1000, updatedBy: 'user1', updatedAppId: 'app1' }, + }, + }, + ]); }); test('should not apply view query string param when isMetadataRedesign is false even if isConfidenceScoreEnabled is true', async () => { diff --git a/src/api/__tests__/utils.test.js b/src/api/__tests__/utils.test.js index 378dde3a88..ea1b76d899 100644 --- a/src/api/__tests__/utils.test.js +++ b/src/api/__tests__/utils.test.js @@ -7,6 +7,7 @@ import { handleOnAbort, isDetailedFieldValue, mapDetailedFieldToConfidenceScore, + mergeDetailedAndHydratedInstances, parseTargetLocation, } from '../utils'; import { threadedComments, threadedCommentsFormatted } from '../fixtures'; @@ -251,4 +252,142 @@ describe('api/utils', () => { expect(formatMetadataFieldValue(taxonomyField, [{ id, displayName }])).toEqual(expectedValue); }); }); + + describe('mergeDetailedAndHydratedInstances()', () => { + test('should replace detailed values with hydrated values for taxonomy fields', () => { + const detailedEntries = [ + { + $id: 'inst-1', + $template: 'template1', + $scope: 'enterprise', + region: { + values: ['uuid-1'], + details: { updatedAt: 1000, updatedBy: 'user1', updatedAppId: 'app1' }, + }, + name: { + values: 'Test Name', + details: { updatedAt: 2000, updatedBy: 'user2', updatedAppId: 'app2' }, + }, + }, + ]; + const hydratedEntries = [ + { + $id: 'inst-1', + $template: 'template1', + $scope: 'enterprise', + region: [{ id: 'uuid-1', displayName: 'Japan', level: '1', nodePath: [] }], + name: 'Test Name', + }, + ]; + + const result = mergeDetailedAndHydratedInstances(detailedEntries, hydratedEntries); + + expect(result).toEqual([ + { + $id: 'inst-1', + $template: 'template1', + $scope: 'enterprise', + region: { + values: [{ id: 'uuid-1', displayName: 'Japan', level: '1', nodePath: [] }], + details: { updatedAt: 1000, updatedBy: 'user1', updatedAppId: 'app1' }, + }, + name: { + values: 'Test Name', + details: { updatedAt: 2000, updatedBy: 'user2', updatedAppId: 'app2' }, + }, + }, + ]); + }); + + test('should return detailed entries unchanged when no matching hydrated entry exists', () => { + const detailedEntries = [ + { + $id: 'inst-1', + region: { values: ['uuid-1'], details: {} }, + }, + ]; + const hydratedEntries = []; + + const result = mergeDetailedAndHydratedInstances(detailedEntries, hydratedEntries); + + expect(result).toEqual(detailedEntries); + }); + + test('should not modify $-prefixed system fields', () => { + const detailedEntries = [ + { + $id: 'inst-1', + $scope: 'enterprise', + $template: 'tmpl', + field1: { values: 'val1', details: {} }, + }, + ]; + const hydratedEntries = [ + { + $id: 'inst-1', + $scope: 'enterprise', + $template: 'tmpl', + field1: 'val1', + }, + ]; + + const result = mergeDetailedAndHydratedInstances(detailedEntries, hydratedEntries); + + expect(result[0].$id).toBe('inst-1'); + expect(result[0].$scope).toBe('enterprise'); + expect(result[0].$template).toBe('tmpl'); + }); + + test('should handle multiple instances', () => { + const detailedEntries = [ + { + $id: 'inst-1', + country: { values: ['id-1'], details: { updatedAt: 100 } }, + }, + { + $id: 'inst-2', + city: { values: ['id-2'], details: { updatedAt: 200 } }, + }, + ]; + const hydratedEntries = [ + { + $id: 'inst-2', + city: [{ id: 'id-2', displayName: 'Tokyo' }], + }, + { + $id: 'inst-1', + country: [{ id: 'id-1', displayName: 'Japan' }], + }, + ]; + + const result = mergeDetailedAndHydratedInstances(detailedEntries, hydratedEntries); + + expect(result[0].country.values).toEqual([{ id: 'id-1', displayName: 'Japan' }]); + expect(result[0].country.details).toEqual({ updatedAt: 100 }); + expect(result[1].city.values).toEqual([{ id: 'id-2', displayName: 'Tokyo' }]); + expect(result[1].city.details).toEqual({ updatedAt: 200 }); + }); + + test('should skip non-detailed fields during merge', () => { + const detailedEntries = [ + { + $id: 'inst-1', + plainField: 'just a string', + detailedField: { values: 'val', details: {} }, + }, + ]; + const hydratedEntries = [ + { + $id: 'inst-1', + plainField: 'just a string', + detailedField: 'val', + }, + ]; + + const result = mergeDetailedAndHydratedInstances(detailedEntries, hydratedEntries); + + expect(result[0].plainField).toBe('just a string'); + expect(result[0].detailedField).toEqual({ values: 'val', details: {} }); + }); + }); }); diff --git a/src/api/utils.js b/src/api/utils.js index 43d79ed74c..3f3eaa5cf8 100644 --- a/src/api/utils.js +++ b/src/api/utils.js @@ -11,6 +11,7 @@ import type { MetadataConfidenceScoreData, MetadataDetailedFieldValue, MetadataFieldValue, + MetadataInstanceV2, MetadataTargetLocationEntry, MetadataTemplateField, } from '../common/types/metadata'; @@ -94,6 +95,39 @@ const parseTargetLocation = (fieldValue: any): ?Array, + hydratedEntries: Array, +): Array => { + const hydratedById: { [string]: MetadataInstanceV2 } = {}; + hydratedEntries.forEach(entry => { + hydratedById[entry.$id] = entry; + }); + + return detailedEntries.map(detailedEntry => { + const hydratedEntry = hydratedById[detailedEntry.$id]; + if (!hydratedEntry) { + return detailedEntry; + } + + const merged = { ...detailedEntry }; + Object.keys(merged).forEach(key => { + if (key.startsWith('$')) { + return; + } + + if (isDetailedFieldValue(merged[key]) && key in hydratedEntry) { + merged[key] = { + ...merged[key], + values: hydratedEntry[key], + }; + } + }); + + return merged; + }); +}; + const handleOnAbort = (xhr: Xhr) => { xhr.abort(); @@ -107,5 +141,6 @@ export { handleOnAbort, isDetailedFieldValue, mapDetailedFieldToConfidenceScore, + mergeDetailedAndHydratedInstances, parseTargetLocation, };