From 4d53d885e3083f56e0226444322795d1349b2b22 Mon Sep 17 00:00:00 2001 From: Anna Stepchenko Date: Thu, 9 Jul 2026 16:33:01 +0200 Subject: [PATCH 1/3] feat(metadata-editor): wire new MetadataTaxonomyPicker into redesigned sidebar --- .../MetadataInstanceEditor.tsx | 11 +- .../MetadataSidebarRedesign.tsx | 33 +- .../__tests__/metadataTaxonomyFetcher.test.ts | 543 +++++++++++++++--- .../fetchers/metadataTaxonomyFetcher.ts | 186 +++++- ...MetadataSidebarRedesign-visual.stories.tsx | 56 ++ 5 files changed, 730 insertions(+), 99 deletions(-) diff --git a/src/elements/content-sidebar/MetadataInstanceEditor.tsx b/src/elements/content-sidebar/MetadataInstanceEditor.tsx index 5336e50d45..7d870531ed 100644 --- a/src/elements/content-sidebar/MetadataInstanceEditor.tsx +++ b/src/elements/content-sidebar/MetadataInstanceEditor.tsx @@ -5,7 +5,10 @@ import { type MetadataTemplateField, type MetadataTemplateInstance, } from '@box/metadata-editor'; -import { TaxonomyOptionsFetcher } from '@box/metadata-editor/lib/components/metadata-editor-fields/components/metadata-taxonomy-field/types.js'; +import { + CreateTaxonomyItemsService, + TaxonomyOptionsFetcher, +} from '@box/metadata-editor/lib/components/metadata-editor-fields/components/metadata-taxonomy-field/types.js'; import React, { useContext } from 'react'; import PreviewContext, { type PreviewContextType } from '../content-preview/PreviewContext'; import { @@ -23,6 +26,7 @@ export interface MetadataInstanceEditorProps { isDeleteConfirmationModalCheckboxEnabled: boolean; isLargeFile: boolean; isMetadataMultiLevelTaxonomyFieldEnabled: boolean; + isMetadataTaxonomyPickerEnabled?: boolean; isUnsavedChangesModalOpen: boolean; onCancel: () => void; onDelete: (metadataInstance: MetadataTemplateInstance) => void; @@ -32,6 +36,7 @@ export interface MetadataInstanceEditorProps { setIsUnsavedChangesModalOpen: (isUnsavedChangesModalOpen: boolean) => void; shouldShowOnlyReviewFields?: boolean; taxonomyOptionsFetcher: TaxonomyOptionsFetcher; + createTaxonomyItemsService?: CreateTaxonomyItemsService; template: MetadataTemplateInstance; isAdvancedExtractAgentEnabled?: boolean; isConfidenceScoreReviewEnabled?: boolean; @@ -50,6 +55,7 @@ const MetadataInstanceEditor: React.FC = ({ isDeleteConfirmationModalCheckboxEnabled, isLargeFile, isMetadataMultiLevelTaxonomyFieldEnabled, + isMetadataTaxonomyPickerEnabled = false, isUnsavedChangesModalOpen, onCancel, onDelete, @@ -59,6 +65,7 @@ const MetadataInstanceEditor: React.FC = ({ setIsUnsavedChangesModalOpen, shouldShowOnlyReviewFields = false, taxonomyOptionsFetcher, + createTaxonomyItemsService, template, isAdvancedExtractAgentEnabled = false, isConfidenceScoreReviewEnabled = false, @@ -74,6 +81,8 @@ const MetadataInstanceEditor: React.FC = ({ { + const field = editingTemplate?.fields?.find(f => f.key === fieldKey && f.type === 'taxonomy'); + if (!field) { + return undefined; + } + return { + levels: field.levels, + selectableLevels: field.optionsRules?.selectableLevels, + }; + }, + [editingTemplate], + ); + + const taxonomyItemsServiceCreator = useMemo( + () => createTaxonomyItemsService(api, fileId, resolveTaxonomyFieldConfig), + [api, fileId, resolveTaxonomyFieldConfig], + ); + useEffect(() => { if (createSessionRequest && fileId && !isSessionInitiated.current) { isSessionInitiated.current = true; @@ -369,6 +394,7 @@ function MetadataSidebarRedesign({ isDeleteConfirmationModalCheckboxEnabled={isDeleteConfirmationModalCheckboxEnabled} isLargeFile={isLargeFile} isMetadataMultiLevelTaxonomyFieldEnabled={isMetadataMultiLevelTaxonomyFieldEnabled} + isMetadataTaxonomyPickerEnabled={isMetadataTaxonomyPickerEnabled} isUnsavedChangesModalOpen={isUnsavedChangesModalOpen} onCancel={handleCancel} onDelete={handleDeleteInstance} @@ -378,6 +404,9 @@ function MetadataSidebarRedesign({ setIsUnsavedChangesModalOpen={handleUnsavedChangesModalOpen} shouldShowOnlyReviewFields={shouldShowOnlyReviewFields} taxonomyOptionsFetcher={taxonomyOptionsFetcher} + createTaxonomyItemsService={ + isMetadataTaxonomyPickerEnabled ? taxonomyItemsServiceCreator : undefined + } template={editingTemplate} isAdvancedExtractAgentEnabled={isAdvancedExtractAgentEnabled} isConfidenceScoreReviewEnabled={isConfidenceScoreReviewEnabled} diff --git a/src/elements/content-sidebar/__tests__/metadataTaxonomyFetcher.test.ts b/src/elements/content-sidebar/__tests__/metadataTaxonomyFetcher.test.ts index 0305741a15..947c4e48f4 100644 --- a/src/elements/content-sidebar/__tests__/metadataTaxonomyFetcher.test.ts +++ b/src/elements/content-sidebar/__tests__/metadataTaxonomyFetcher.test.ts @@ -1,5 +1,11 @@ import type { PaginationQueryInput } from '@box/metadata-editor'; -import { metadataTaxonomyFetcher, metadataTaxonomyNodeAncestorsFetcher } from '../fetchers/metadataTaxonomyFetcher'; +import type { Level } from '@box/combobox-with-api'; +import { + createTaxonomyItemsService, + metadataTaxonomyFetcher, + metadataTaxonomyNodeAncestorsFetcher, + type TaxonomyFieldConfig, +} from '../fetchers/metadataTaxonomyFetcher'; import type API from '../../../api'; describe('metadataTaxonomyFetcher', () => { @@ -22,25 +28,25 @@ describe('metadataTaxonomyFetcher', () => { test('should fetch metadata options and return formatted data - new naming convention', async () => { const mockMetadataOptions = { entries: [ - { - id: 'opt1', - display_name: 'Option 1', - level: '1', + { + id: 'opt1', + display_name: 'Option 1', + level: '1', parentId: 'parent1', nodePath: ['node1', 'node2'], deprecated: false, - ancestors: null, - selectable: false + ancestors: null, + selectable: false, }, - { - id: 'opt2', - display_name: 'Option 2', - level: '2', + { + id: 'opt2', + display_name: 'Option 2', + level: '2', parentId: 'parent2', nodePath: ['node1', 'node3'], deprecated: true, - ancestors: [{ display_name: 'Option 1', foo: 'bar' }], - selectable: true + ancestors: [{ display_name: 'Option 1', foo: 'bar' }], + selectable: true, }, ], limit: 100, @@ -53,25 +59,25 @@ describe('metadataTaxonomyFetcher', () => { const expectedResult = { options: [ - { - value: 'opt1', - displayValue: 'Option 1', - level: '1', + { + value: 'opt1', + displayValue: 'Option 1', + level: '1', parentId: 'parent1', nodePath: ['node1', 'node2'], deprecated: false, - ancestors: undefined, - selectable: false + ancestors: undefined, + selectable: false, }, - { - value: 'opt2', - displayValue: 'Option 2', - level: '2', + { + value: 'opt2', + displayValue: 'Option 2', + level: '2', parentId: 'parent2', nodePath: ['node1', 'node3'], deprecated: true, - ancestors: [{ displayName: 'Option 1', foo: 'bar' }], - selectable: true + ancestors: [{ displayName: 'Option 1', foo: 'bar' }], + selectable: true, }, ], marker: 'marker_1', @@ -94,25 +100,25 @@ describe('metadataTaxonomyFetcher', () => { test('should fetch metadata options and return formatted data - old naming convention', async () => { const mockMetadataOptions = { entries: [ - { - id: 'opt1', - displayName: 'Option 1', - level: '1', + { + id: 'opt1', + displayName: 'Option 1', + level: '1', parentId: 'parent1', nodePath: ['node1', 'node2'], deprecated: false, - ancestors: null, - selectable: false + ancestors: null, + selectable: false, }, - { - id: 'opt2', - displayName: 'Option 2', - level: '2', + { + id: 'opt2', + displayName: 'Option 2', + level: '2', parentId: 'parent2', nodePath: ['node1', 'node3'], deprecated: true, - ancestors: [{ displayName: 'Option 1', foo: 'bar' }], - selectable: true + ancestors: [{ displayName: 'Option 1', foo: 'bar' }], + selectable: true, }, ], limit: 50, @@ -125,25 +131,25 @@ describe('metadataTaxonomyFetcher', () => { const expectedResult = { options: [ - { - value: 'opt1', - displayValue: 'Option 1', - level: '1', + { + value: 'opt1', + displayValue: 'Option 1', + level: '1', parentId: 'parent1', nodePath: ['node1', 'node2'], deprecated: false, - ancestors: undefined, - selectable: false + ancestors: undefined, + selectable: false, }, - { - value: 'opt2', - displayValue: 'Option 2', - level: '2', + { + value: 'opt2', + displayValue: 'Option 2', + level: '2', parentId: 'parent2', nodePath: ['node1', 'node3'], deprecated: true, - ancestors: [{ displayName: 'Option 1', foo: 'bar' }], - selectable: true + ancestors: [{ displayName: 'Option 1', foo: 'bar' }], + selectable: true, }, ], marker: 'marker_1', @@ -183,10 +189,10 @@ describe('metadataTaxonomyFetcher', () => { test('should not include limit and totalResultCount when not provided in metadataOptions', async () => { const mockMetadataOptions = { entries: [ - { - id: 'opt1', - display_name: 'Option 1', - level: '1', + { + id: 'opt1', + display_name: 'Option 1', + level: '1', parentId: 'parent1', }, ], @@ -198,10 +204,10 @@ describe('metadataTaxonomyFetcher', () => { expect(result).toEqual({ options: [ - { - value: 'opt1', - displayValue: 'Option 1', - level: '1', + { + value: 'opt1', + displayValue: 'Option 1', + level: '1', parentId: 'parent1', nodePath: undefined, deprecated: undefined, @@ -218,13 +224,15 @@ describe('metadataTaxonomyFetcher', () => { test('should set marker to null if not provided in options', async () => { const mockMetadataOptions = { - entries: [{ - id: 'opt1', - display_name: 'Option 1', - parentId: undefined, - nodePath: undefined, - deprecated: undefined, - }], + entries: [ + { + id: 'opt1', + display_name: 'Option 1', + parentId: undefined, + nodePath: undefined, + deprecated: undefined, + }, + ], }; apiMock.getMetadataAPI(false).getMetadataOptions.mockResolvedValue(mockMetadataOptions); @@ -232,13 +240,15 @@ describe('metadataTaxonomyFetcher', () => { const result = await metadataTaxonomyFetcher(apiMock, fileId, scope, templateKey, fieldKey, level, {}); const expectedResult = { - options: [{ - value: 'opt1', - displayValue: 'Option 1', - parentId: undefined, - nodePath: undefined, - deprecated: undefined, - }], + options: [ + { + value: 'opt1', + displayValue: 'Option 1', + parentId: undefined, + nodePath: undefined, + deprecated: undefined, + }, + ], marker: null, }; @@ -248,12 +258,12 @@ describe('metadataTaxonomyFetcher', () => { test('should handle missing new fields for backward compatibility', async () => { const mockMetadataOptions = { entries: [ - { - id: 'opt1', - display_name: 'Option 1', - level: '1', - ancestors: null, - selectable: false + { + id: 'opt1', + display_name: 'Option 1', + level: '1', + ancestors: null, + selectable: false, }, ], }; @@ -264,15 +274,15 @@ describe('metadataTaxonomyFetcher', () => { const expectedResult = { options: [ - { - value: 'opt1', - displayValue: 'Option 1', - level: '1', + { + value: 'opt1', + displayValue: 'Option 1', + level: '1', parentId: undefined, nodePath: undefined, deprecated: undefined, - ancestors: undefined, - selectable: false + ancestors: undefined, + selectable: false, }, ], marker: 'marker_1', @@ -417,22 +427,22 @@ describe('metadataTaxonomyNodeAncestorsFetcher (new keys naming convention)', () test('should fetch taxonomy and node data and return formatted data', async () => { const mockTaxonomy = { - display_name: 'Geography', + displayName: 'Geography', namespace: 'my_enterprise', id: 'my_id', key: 'geography', levels: [ - { level: 1, display_name: 'Level 1', description: 'Description 1' }, - { level: 2, display_name: 'Level 2', description: 'Description 2' }, - { level: 3, display_name: 'Level 3', description: 'Description 3' }, + { level: 1, displayName: 'Level 1', description: 'Description 1' }, + { level: 2, displayName: 'Level 2', description: 'Description 2' }, + { level: 3, displayName: 'Level 3', description: 'Description 3' }, ], }; const mockTaxonomyNode = { id: 'node_abc', level: 1, - display_name: 'Node ABC', - ancestors: [{ id: 'ancestor_1', level: 2, display_name: 'Ancestor 1' }], + displayName: 'Node ABC', + ancestors: [{ id: 'ancestor_1', level: 2, displayName: 'Ancestor 1' }], }; apiMock.getMetadataAPI(false).getMetadataTaxonomy.mockResolvedValue(mockTaxonomy); @@ -471,17 +481,17 @@ describe('metadataTaxonomyNodeAncestorsFetcher (new keys naming convention)', () test('should handle empty ancestors array', async () => { const mockTaxonomy = { - display_name: 'Geography', + displayName: 'Geography', namespace: 'my_enterprise', id: 'my_id', key: 'geography', - levels: [{ level: 1, display_name: 'Level 1', description: 'Description 1' }], + levels: [{ level: 1, displayName: 'Level 1', description: 'Description 1' }], }; const mockTaxonomyNode = { id: 'node_abc', level: 1, - display_name: 'Node ABC', + displayName: 'Node ABC', ancestors: [], }; @@ -521,3 +531,352 @@ describe('metadataTaxonomyNodeAncestorsFetcher (new keys naming convention)', () ); }); }); + +describe('createTaxonomyItemsService', () => { + const fileId = 'file_1'; + const scope = 'enterprise_1'; + const templateKey = 'myTaxonomy'; + const fieldKey = 'multiLevel'; + const identity = { scope, templateKey, fieldKey }; + const makeLevel = (level: number): Level => ({ + level, + displayName: `Level ${level}`, + description: `Level ${level}`, + }); + const multiLevels: Level[] = [makeLevel(1), makeLevel(2), makeLevel(3), makeLevel(4)]; + const singleLevels: Level[] = [makeLevel(1)]; + // Multi-level picker mode: multiple selectable levels means the popover + // drills from level 1 downward. + const multiLevelResolver = (): TaxonomyFieldConfig => ({ + levels: multiLevels, + selectableLevels: [1, 2, 3, 4], + }); + // Single-level *taxonomy* (only one level of nodes exists at all). + const singleLevelResolver = (): TaxonomyFieldConfig => ({ + levels: singleLevels, + selectableLevels: [1], + }); + // Single-level *picker mode* on a multi-level taxonomy: exactly one + // selectable level (may be any level, not just level 1). + const singleLevelPickerResolver = + (targetLevel: number): (() => TaxonomyFieldConfig) => + () => ({ + levels: multiLevels, + selectableLevels: [targetLevel], + }); + + let getMetadataOptions: jest.Mock; + let apiMock: jest.Mocked; + + beforeEach(() => { + getMetadataOptions = jest.fn(); + apiMock = { + getMetadataAPI: jest.fn().mockReturnValue({ getMetadataOptions }), + } as unknown as jest.Mocked; + }); + + const buildEntry = (overrides: Record = {}) => ({ + id: 'node-1', + display_name: 'Node 1', + level: 1, + selectable: false, + ancestors: [], + ...overrides, + }); + + test('scopes multi-level root browse to level 1 so the API does not flatten the tree', async () => { + getMetadataOptions.mockResolvedValue({ + entries: [buildEntry({ id: 'root-a', display_name: 'Root A', level: 1 })], + next_marker: null, + }); + + const service = createTaxonomyItemsService(apiMock, fileId, multiLevelResolver)(identity); + const response = await service.getNodes(null, { limit: 100 }); + + expect(getMetadataOptions).toHaveBeenCalledTimes(1); + const [, , , , levelArg, optionsArg] = getMetadataOptions.mock.calls[0]; + expect(levelArg).toBe(1); + expect(optionsArg).toMatchObject({ level: 1 }); + expect(optionsArg).not.toHaveProperty('ancestorId'); + + expect(response.entries).toEqual([expect.objectContaining({ id: 'root-a', displayName: 'Root A', level: 1 })]); + }); + + test('scopes single-level taxonomy browse to the single selectable level', async () => { + getMetadataOptions.mockResolvedValue({ + entries: [ + buildEntry({ id: 'blue', display_name: 'Blue', level: 1 }), + buildEntry({ id: 'red', display_name: 'Red', level: 1 }), + ], + next_marker: null, + }); + + const service = createTaxonomyItemsService(apiMock, fileId, singleLevelResolver)(identity); + const response = await service.getNodes(null, { limit: 100 }); + + const optionsArg = getMetadataOptions.mock.calls[0][5]; + expect(optionsArg).toMatchObject({ level: 1 }); + expect(optionsArg).not.toHaveProperty('ancestorId'); + expect(response.entries).toHaveLength(2); + }); + + test('does not send a level filter when the field config is unknown (safe default)', async () => { + getMetadataOptions.mockResolvedValue({ entries: [], next_marker: null }); + + const service = createTaxonomyItemsService(apiMock, fileId)(identity); + await service.getNodes(null, { limit: 100 }); + + expect(getMetadataOptions.mock.calls[0][5]).not.toHaveProperty('level'); + }); + + test('loads the exact selectable level when the picker is in single-level mode (deep level)', async () => { + getMetadataOptions.mockResolvedValue({ + entries: [ + buildEntry({ + id: 'leaf-a', + display_name: 'Leaf A', + level: 3, + selectable: true, + ancestors: [ + { id: 'r1', display_name: 'Root 1', level: 1 }, + { id: 'r2', display_name: 'Region 2', level: 2 }, + ], + }), + ], + next_marker: null, + }); + + const service = createTaxonomyItemsService(apiMock, fileId, singleLevelPickerResolver(3))(identity); + const response = await service.getNodes(null, { limit: 100 }); + + const [, , , , levelArg, optionsArg] = getMetadataOptions.mock.calls[0]; + expect(levelArg).toBe(3); + expect(optionsArg).toMatchObject({ level: 3 }); + expect(optionsArg).not.toHaveProperty('ancestorId'); + expect(response.entries[0]).toEqual(expect.objectContaining({ id: 'leaf-a', level: 3, selectable: true })); + }); + + test('marks nodes as leaves (hasChildren: false) in single-level picker mode', async () => { + // Level-3 nodes on a taxonomy that has 4 levels — normally we would + // infer `hasChildren: true` here, but single-level picker mode should + // suppress the drill-down chevron on every row. + getMetadataOptions.mockResolvedValue({ + entries: [ + buildEntry({ id: 'leaf-a', level: 3, selectable: true }), + // Even an explicit `has_children: true` from the backend is + // overridden — the picker is in flat single-level mode. + buildEntry({ id: 'leaf-b', level: 3, selectable: true, has_children: true }), + ], + next_marker: null, + }); + + const service = createTaxonomyItemsService(apiMock, fileId, singleLevelPickerResolver(3))(identity); + const response = await service.getNodes(null, { limit: 100 }); + + expect(response.entries).toEqual([ + expect.objectContaining({ id: 'leaf-a', hasChildren: false }), + expect.objectContaining({ id: 'leaf-b', hasChildren: false }), + ]); + }); + + test('marks single-level picker search results as leaves too', async () => { + getMetadataOptions.mockResolvedValue({ + entries: [buildEntry({ id: 'leaf-a', level: 3, selectable: true, has_children: true })], + next_marker: null, + }); + + const service = createTaxonomyItemsService(apiMock, fileId, singleLevelPickerResolver(3))(identity); + const response = await service.searchNodes('leaf', { limit: 100, levelFilter: 3 }); + + expect(response.entries[0]).toEqual(expect.objectContaining({ id: 'leaf-a', hasChildren: false })); + }); + + test('ignores drill-down requests in single-level picker mode and re-fetches the flat root list', async () => { + getMetadataOptions.mockResolvedValue({ entries: [], next_marker: null }); + + const service = createTaxonomyItemsService(apiMock, fileId, singleLevelPickerResolver(3))(identity); + // Even if a caller somehow invokes getNodes with a parentId, the + // service does not drill: the request stays scoped to the flat + // selectable level with no ancestor filter. + await service.getNodes('some-level-3-id', { limit: 100 }); + + const optionsArg = getMetadataOptions.mock.calls[0][5]; + expect(optionsArg).toMatchObject({ level: 3 }); + expect(optionsArg).not.toHaveProperty('ancestorId'); + }); + + test('walks multiple levels deep, requesting parentLevel + 1 with ancestor at each step', async () => { + getMetadataOptions + .mockResolvedValueOnce({ + entries: [buildEntry({ id: 'a', level: 1 })], + next_marker: null, + }) + .mockResolvedValueOnce({ + entries: [buildEntry({ id: 'b', level: 2 })], + next_marker: null, + }) + .mockResolvedValueOnce({ + entries: [buildEntry({ id: 'c', level: 3 })], + next_marker: null, + }); + + const service = createTaxonomyItemsService(apiMock, fileId, multiLevelResolver)(identity); + await service.getNodes(null, { limit: 100 }); + await service.getNodes('a', { limit: 100 }); + await service.getNodes('b', { limit: 100 }); + + expect(getMetadataOptions.mock.calls[1][5]).toMatchObject({ level: 2, ancestorId: 'a' }); + expect(getMetadataOptions.mock.calls[2][5]).toMatchObject({ level: 3, ancestorId: 'b' }); + }); + + test('forwards pagination markers on browse', async () => { + getMetadataOptions.mockResolvedValue({ entries: [], next_marker: 'next-page' }); + + const service = createTaxonomyItemsService(apiMock, fileId, multiLevelResolver)(identity); + const response = await service.getNodes(null, { limit: 100, marker: 'page-2' }); + + expect(getMetadataOptions.mock.calls[0][5]).toMatchObject({ marker: 'page-2', level: 1 }); + expect(response.next_marker).toBe('next-page'); + }); + + test('uses ancestor levels returned from search to scope subsequent drill-down', async () => { + getMetadataOptions + .mockResolvedValueOnce({ + entries: [ + buildEntry({ + id: 'deep-node', + display_name: 'Deep Node', + level: 4, + ancestors: [ + { id: 'root-a', display_name: 'Root A', level: 1 }, + { id: 'mid-b', display_name: 'Mid B', level: 3 }, + ], + }), + ], + next_marker: null, + }) + .mockResolvedValueOnce({ + entries: [buildEntry({ id: 'child-of-mid', level: 4 })], + next_marker: null, + }); + + const service = createTaxonomyItemsService(apiMock, fileId, multiLevelResolver)(identity); + await service.searchNodes('deep', { limit: 100 }); + await service.getNodes('mid-b', { limit: 100 }); + + expect(getMetadataOptions.mock.calls[1][5]).toMatchObject({ level: 4, ancestorId: 'mid-b' }); + }); + + test('falls back to level 1 when drilling into an unknown parent of a multi-level taxonomy', async () => { + getMetadataOptions.mockResolvedValue({ entries: [], next_marker: null }); + + const service = createTaxonomyItemsService(apiMock, fileId, multiLevelResolver)(identity); + await service.getNodes('never-seen', { limit: 100 }); + + expect(getMetadataOptions.mock.calls[0][5]).toMatchObject({ level: 1, ancestorId: 'never-seen' }); + }); + + test('search forwards query, levelFilter, and parent scope', async () => { + getMetadataOptions.mockResolvedValue({ entries: [], next_marker: null }); + + const service = createTaxonomyItemsService(apiMock, fileId)(identity); + await service.searchNodes('books', { limit: 100, parentId: 'root-a', levelFilter: 3, marker: 'm' }); + + expect(getMetadataOptions.mock.calls[0][5]).toMatchObject({ + searchInput: 'books', + level: 3, + ancestorId: 'root-a', + marker: 'm', + }); + }); + + test('search omits level filter when the picker does not restrict by level', async () => { + getMetadataOptions.mockResolvedValue({ entries: [], next_marker: null }); + + const service = createTaxonomyItemsService(apiMock, fileId)(identity); + await service.searchNodes('anything', { limit: 100 }); + + const optionsArg = getMetadataOptions.mock.calls[0][5]; + expect(optionsArg).toMatchObject({ searchInput: 'anything' }); + expect(optionsArg).not.toHaveProperty('level'); + expect(optionsArg).not.toHaveProperty('ancestorId'); + }); + + test('derives hasChildren from the template levels when the backend omits it', async () => { + getMetadataOptions.mockResolvedValue({ + entries: [buildEntry({ id: 'root-a', level: 1 }), buildEntry({ id: 'leaf', level: 3 })], + next_marker: null, + }); + + const resolveField = jest.fn().mockReturnValue({ + levels: [makeLevel(1), makeLevel(2), makeLevel(3)], + selectableLevels: [1, 2, 3], + }); + const service = createTaxonomyItemsService(apiMock, fileId, resolveField)(identity); + const response = await service.getNodes(null, { limit: 100 }); + + expect(resolveField).toHaveBeenCalledWith(templateKey, fieldKey); + expect(response.entries).toEqual([ + expect.objectContaining({ id: 'root-a', hasChildren: true }), + expect.objectContaining({ id: 'leaf', hasChildren: false }), + ]); + }); + + test('honors an explicit hasChildren flag from the backend over the inferred value', async () => { + getMetadataOptions.mockResolvedValue({ + entries: [buildEntry({ id: 'root-a', level: 1, has_children: false })], + next_marker: null, + }); + + const resolveField = jest.fn().mockReturnValue({ + levels: [makeLevel(1), makeLevel(2)], + selectableLevels: [1, 2], + }); + const service = createTaxonomyItemsService(apiMock, fileId, resolveField)(identity); + const response = await service.getNodes(null, { limit: 100 }); + + expect(response.entries[0]).toEqual(expect.objectContaining({ id: 'root-a', hasChildren: false })); + }); + + test('maps ancestor arrays into breadcrumb-ready objects and skips bare-id ancestors', async () => { + getMetadataOptions.mockResolvedValue({ + entries: [ + buildEntry({ + id: 'leaf', + level: 2, + ancestors: [{ id: 'root-a', display_name: 'Root A', level: 1 }, 'bare-string-id'], + }), + ], + next_marker: null, + }); + + const service = createTaxonomyItemsService(apiMock, fileId)(identity); + const response = await service.searchNodes('x', { limit: 100 }); + + expect(response.entries[0].ancestors).toEqual([{ id: 'root-a', displayName: 'Root A', level: 1 }]); + }); + + test('separate service instances keep separate level caches (per-field isolation)', async () => { + getMetadataOptions + .mockResolvedValueOnce({ + entries: [buildEntry({ id: 'shared-id', level: 1 })], + next_marker: null, + }) + .mockResolvedValueOnce({ entries: [], next_marker: null }); + + const factory = createTaxonomyItemsService(apiMock, fileId, multiLevelResolver); + const serviceA = factory({ scope, templateKey, fieldKey: 'fieldA' }); + const serviceB = factory({ scope, templateKey, fieldKey: 'fieldB' }); + + await serviceA.getNodes(null, { limit: 100 }); + // serviceB has never seen 'shared-id', so it cannot know it is level 1 + // — the request falls back to level 1 (root scope) rather than reusing + // serviceA's cache. + await serviceB.getNodes('shared-id', { limit: 100 }); + + expect(getMetadataOptions.mock.calls[1][5]).toMatchObject({ + level: 1, + ancestorId: 'shared-id', + }); + }); +}); diff --git a/src/elements/content-sidebar/fetchers/metadataTaxonomyFetcher.ts b/src/elements/content-sidebar/fetchers/metadataTaxonomyFetcher.ts index 22bae5135f..a4e35254b0 100644 --- a/src/elements/content-sidebar/fetchers/metadataTaxonomyFetcher.ts +++ b/src/elements/content-sidebar/fetchers/metadataTaxonomyFetcher.ts @@ -1,4 +1,14 @@ -import { TreeQueryInput, TreeOptionType, FetcherResponse } from '@box/combobox-with-api'; +import { TreeQueryInput, TreeOptionType, FetcherResponse, type Level } from '@box/combobox-with-api'; +import type { + FetchParams, + FetchResponse, + SearchParams, + TaxonomyAncestor, + TaxonomyItemsService, + TaxonomyNode, + TaxonomySearchResult, +} from '@box/metadata-taxonomy-picker'; +import type { CreateTaxonomyItemsService } from '@box/metadata-editor/lib/components/metadata-editor-fields/components/metadata-taxonomy-field/types.js'; import type API from '../../../api'; import type { MetadataOptionEntry } from '../../../common/types/metadata'; @@ -24,11 +34,16 @@ export const metadataTaxonomyFetcher = async ( parentId: metadataOption.parentId, nodePath: metadataOption.nodePath, deprecated: metadataOption.deprecated, - ancestors: metadataOption.ancestors?.map(({display_name, displayName, ...rest}) => ({...rest, displayName: display_name || displayName })), + ancestors: metadataOption.ancestors?.map(({ display_name, displayName, ...rest }) => ({ + ...rest, + displayName: display_name || displayName, + })), selectable: metadataOption.selectable, })), marker, - ...(metadataOptions.total_result_count !== undefined && { totalResultCount: metadataOptions.total_result_count }), + ...(metadataOptions.total_result_count !== undefined && { + totalResultCount: metadataOptions.total_result_count, + }), ...(metadataOptions.limit !== undefined && { limit: metadataOptions.limit }), } as FetcherResponse; }; @@ -84,7 +99,11 @@ export const metadataTaxonomyNodeAncestorsFetcher = async ( const levelData = levelsMap.get(ancestor.level); if (levelData) { - levelsMap.set(ancestor.level, { ...levelData, levelValue: ancestor.displayName || ancestor.display_name, id: ancestor.id }); + levelsMap.set(ancestor.level, { + ...levelData, + levelValue: ancestor.displayName || ancestor.display_name, + id: ancestor.id, + }); } } } @@ -95,3 +114,162 @@ export const metadataTaxonomyNodeAncestorsFetcher = async ( // Return the hydrated levels as an array return hydratedLevels; }; + +// Upstream sources mix snake_case (/options) and camelCase (taxonomy-node), so read both. +const getDisplayName = (option: MetadataOptionEntry): string => option.display_name || option.displayName || ''; + +const mapAncestors = (ancestors: MetadataOptionEntry['ancestors']): TaxonomyAncestor[] => { + const list = ancestors ?? []; + // Bare-string ancestor ids lack the data needed to render a breadcrumb. + return list + .filter(ancestor => !!ancestor && typeof ancestor === 'object') + .map(ancestor => ({ + id: ancestor.id, + displayName: ancestor.display_name || ancestor.displayName || '', + level: Number(ancestor.level), + })); +}; + +// Prefers backend `has_children`; falls back to depth. `forceLeaf` disables drill-down. +const getHasChildren = (option: MetadataOptionEntry, maxLevel?: number, forceLeaf?: boolean): boolean => { + if (forceLeaf) { + return false; + } + if (typeof option.has_children === 'boolean') { + return option.has_children; + } + if (typeof option.hasChildren === 'boolean') { + return option.hasChildren; + } + return maxLevel != null ? Number(option.level) < maxLevel : false; +}; + +// Always producing `ancestors` lets one mapper satisfy both browse (optional) and search (required). +const mapOption = ( + option: MetadataOptionEntry, + maxLevel?: number, + forceLeaf?: boolean, +): TaxonomyNode & TaxonomySearchResult => ({ + id: option.id, + displayName: getDisplayName(option), + level: Number(option.level), + selectable: !!option.selectable, + hasChildren: getHasChildren(option, maxLevel, forceLeaf), + ancestors: mapAncestors(option.ancestors), +}); + +const getMaxLevel = (levels?: Level[]): number | undefined => + levels && levels.length > 0 ? Math.max(...levels.map(({ level }) => level)) : undefined; + +export type TaxonomyFieldConfig = { + levels?: Level[]; + selectableLevels?: number[]; +}; + +/** + * Factory for the `MetadataTaxonomyPicker` in `@box/metadata-editor`. + * + * `/options` returns a flat list unless scoped by `level` (+ `ancestor` for drill-down): + * - Single-level picker (`selectableLevels.length === 1`): loads that level directly, every node is a leaf. + * - Multi-level picker: starts at level 1, drills via `parentLevel + 1` with `ancestor=`. + */ +export const createTaxonomyItemsService = + ( + api: API, + fileId: string, + resolveField?: (templateKey: string, fieldKey: string) => TaxonomyFieldConfig | undefined, + ): CreateTaxonomyItemsService => + ({ scope, templateKey, fieldKey }): TaxonomyItemsService => { + const fieldConfig = resolveField?.(templateKey, fieldKey); + const maxLevel = getMaxLevel(fieldConfig?.levels); + const selectableLevels = fieldConfig?.selectableLevels; + const singleSelectableLevel = + selectableLevels && selectableLevels.length === 1 ? Number(selectableLevels[0]) : undefined; + const isSingleLevelPickerMode = singleSelectableLevel != null; + // Multi-level taxonomies need a `level` filter or /options mixes branches. + const isMultiLevelTaxonomy = maxLevel != null && maxLevel > 1; + + // Tracks levels of returned nodes so drill-down can request `parentLevel + 1`. + const nodeLevelById = new Map(); + + const rememberLevels = (entries: MetadataOptionEntry[] | undefined): void => { + if (!entries) { + return; + } + for (const entry of entries) { + if (entry?.id != null && entry.level != null) { + nodeLevelById.set(String(entry.id), Number(entry.level)); + } + if (Array.isArray(entry?.ancestors)) { + for (const ancestor of entry.ancestors) { + if (ancestor && typeof ancestor === 'object' && ancestor.id != null && ancestor.level != null) { + nodeLevelById.set(String(ancestor.id), Number(ancestor.level)); + } + } + } + } + }; + + const getChildLevel = (parentId: string | null): number => { + if (parentId === null) { + return singleSelectableLevel ?? 1; + } + const parentLevel = nodeLevelById.get(parentId); + return parentLevel != null ? parentLevel + 1 : 1; + }; + + const getNodes = async ( + parentId: string | null, + { marker, signal }: FetchParams, + ): Promise> => { + // Single-level picker is flat — defensively ignore drill-down. + const effectiveParentId = isSingleLevelPickerMode ? null : parentId; + const childLevel = getChildLevel(effectiveParentId); + const shouldFilterByLevel = isMultiLevelTaxonomy || isSingleLevelPickerMode; + const response = await api + .getMetadataAPI(false) + .getMetadataOptions(fileId, scope, templateKey, fieldKey, childLevel, { + ...(shouldFilterByLevel ? { level: childLevel } : {}), + ...(effectiveParentId ? { ancestorId: effectiveParentId } : {}), + ...(marker ? { marker } : {}), + signal, + }); + + const entries = response.entries ?? []; + rememberLevels(entries); + + return { + entries: entries.map((option: MetadataOptionEntry) => + mapOption(option, maxLevel, isSingleLevelPickerMode), + ), + next_marker: response.next_marker ?? undefined, + }; + }; + + const searchNodes = async ( + query: string, + { marker, signal, parentId, levelFilter }: SearchParams, + ): Promise> => { + const response = await api + .getMetadataAPI(false) + .getMetadataOptions(fileId, scope, templateKey, fieldKey, levelFilter ?? 0, { + searchInput: query, + ...(parentId ? { ancestorId: parentId } : {}), + ...(levelFilter ? { level: levelFilter } : {}), + ...(marker ? { marker } : {}), + signal, + }); + + const entries = response.entries ?? []; + rememberLevels(entries); + + return { + entries: entries.map((option: MetadataOptionEntry) => + mapOption(option, maxLevel, isSingleLevelPickerMode), + ), + next_marker: response.next_marker ?? undefined, + }; + }; + + return { getNodes, searchNodes }; + }; diff --git a/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx b/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx index d03c0cf47f..c0c6c2211c 100644 --- a/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx +++ b/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx @@ -894,6 +894,62 @@ export const EditMultilevelTaxonomy: StoryObj = }, }; +const taxonomyPickerFeatures = { + ...mockFeatures, + 'metadata.multilevelTaxonomy.enabled': true, + 'metadata.taxonomyPicker.enabled': true, +}; + +const openTaxonomyEditor = async (canvas: ReturnType) => { + await waitForLoadingToComplete(canvas); + const editButton = await canvas.findByRole('button', { name: 'Edit My Taxonomy' }); + await userEvent.click(editButton); +}; + +export const ViewMultilevelTaxonomyWithPicker: StoryObj = { + ...ViewMultilevelTaxonomy, + args: { + ...ViewMultilevelTaxonomy.args, + features: taxonomyPickerFeatures, + }, + play: async ({ canvasElement }) => { + await waitForLoadingToComplete(within(canvasElement)); + }, +}; + +export const EditMultilevelTaxonomyWithPicker: StoryObj = { + ...ViewMultilevelTaxonomy, + args: { + ...ViewMultilevelTaxonomy.args, + features: taxonomyPickerFeatures, + }, + play: async ({ canvasElement }) => { + await openTaxonomyEditor(within(canvasElement)); + }, +}; + +export const ViewSinglelevelTaxonomyWithPicker: StoryObj = { + ...ViewSinglelevelTaxonomy, + args: { + ...ViewSinglelevelTaxonomy.args, + features: taxonomyPickerFeatures, + }, + play: async ({ canvasElement }) => { + await waitForLoadingToComplete(within(canvasElement)); + }, +}; + +export const EditSinglelevelTaxonomyWithPicker: StoryObj = { + ...ViewSinglelevelTaxonomy, + args: { + ...ViewSinglelevelTaxonomy.args, + features: taxonomyPickerFeatures, + }, + play: async ({ canvasElement }) => { + await openTaxonomyEditor(within(canvasElement)); + }, +}; + export const EditSinglelevelTaxonomy: StoryObj = { ...ViewSinglelevelTaxonomy, play: async ({ canvasElement }) => { From 33c2ed141fa35ae877680e11ead64173c44f44ce Mon Sep 17 00:00:00 2001 From: Anna Stepchenko Date: Mon, 13 Jul 2026 16:19:55 +0200 Subject: [PATCH 2/3] fix: bump shared features versions --- package.json | 4 ++-- yarn.lock | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index f42b3d1097..de2969e34f 100644 --- a/package.json +++ b/package.json @@ -138,9 +138,9 @@ "@box/frontend": "^11.0.1", "@box/item-icon": "^3.2.0", "@box/languages": "^1.0.0", - "@box/metadata-editor": "^2.2.11", + "@box/metadata-editor": "^2.2.28", "@box/metadata-filter": "^1.80.23", - "@box/metadata-taxonomy-picker": "^3.1.8", + "@box/metadata-taxonomy-picker": "^3.1.25", "@box/metadata-template-browser": "^2.1.7", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", diff --git a/yarn.lock b/yarn.lock index d2d31c8ac3..651c418b9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1179,20 +1179,20 @@ resolved "https://registry.yarnpkg.com/@box/languages/-/languages-1.1.2.tgz#cd4266b3da62da18560d881e10b429653186be29" integrity sha512-d64TGosx+KRmrLZj4CIyLp42LUiEbgBJ8n8cviMQwTJmfU0g+UwZqLjmQZR1j+Q9D64yV4xHzY9K1t5nInWWeQ== -"@box/metadata-editor@^2.2.11": - version "2.2.11" - resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-2.2.11.tgz#6a4875e0c1f3ba3593887ad6694e2b74162853d8" - integrity sha512-ouF4cPC2kzqgVJ9hnMOqtOy+SDd7MMec/igg0EqzGXXGpo8vuIsl3M2zk4qXHS9kTXXDbK+Xq0wb7WMfCj+ccA== +"@box/metadata-editor@^2.2.28": + version "2.2.28" + resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-2.2.28.tgz#4e0f12a0d2c0797855f0beb87fafdea8de5fc44b" + integrity sha512-xa49ovqe7ePMnZh+cq1B6kkCQCq55TQO5+BaB/pits+EPOFnZ5CawZYzzwb8PXIp/kWp2f2yw0D9kSqYnkv6NQ== "@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-taxonomy-picker@^3.1.8": - version "3.1.8" - resolved "https://registry.yarnpkg.com/@box/metadata-taxonomy-picker/-/metadata-taxonomy-picker-3.1.8.tgz#d72f6df53c69b75f9e3fddbdaaca39858d43aade" - integrity sha512-ahlkefldWk1OlMtbD3i8b7yrPgHDkP2IBXvcFC3R+ckSsBjxa2amo63C0lViOHUc9vjZWrutY7EqeKUtTJ7OGw== +"@box/metadata-taxonomy-picker@^3.1.25": + version "3.1.25" + resolved "https://registry.yarnpkg.com/@box/metadata-taxonomy-picker/-/metadata-taxonomy-picker-3.1.25.tgz#ff128f082086ec18b4e24b76b786c915ebaf3064" + integrity sha512-IfcUZEhtta/2nkzo0vilgvn9hp44xdNldpw03Ska+s5Jpo3kZh0CzzooRoMd/w9nhYHn3pW2/U6LmR/X37noEw== "@box/metadata-template-browser@^2.1.7": version "2.1.7" @@ -1752,7 +1752,7 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.6.0.tgz#5b0cb058cc13d9c92d4e561d3538807fa5127c95" integrity sha512-D9B0/3vNg44ZeWbYMpBoXqNP4j6eQD5vNwIlGAuFRRzK/WtT/jvDQW3Bi9kkf3PMDMlM7Yi+73VLUsn5bJcl8A== -"@floating-ui/core@^1.7.2", "@floating-ui/core@^1.7.5": +"@floating-ui/core@^1.7.5": version "1.7.5" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.5.tgz#d4af157a03330af5a60e69da7a4692507ada0622" integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ== @@ -1774,7 +1774,7 @@ dependencies: "@floating-ui/dom" "^1.7.2" -"@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.11": +"@floating-ui/utils@^0.2.11": version "0.2.11" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f" integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg== From 86164ef3743e9d233f17e03ed92f5d5693d6bac1 Mon Sep 17 00:00:00 2001 From: Anna Stepchenko Date: Mon, 13 Jul 2026 21:45:11 +0200 Subject: [PATCH 3/3] refactor: update tests & namings & factory setup --- .../MetadataSidebarRedesign.tsx | 6 +- ...ataSidebarRedesign-taxonomyPicker.test.tsx | 163 ++++++++++++++++++ .../__tests__/metadataTaxonomyFetcher.test.ts | 5 +- .../fetchers/metadataTaxonomyFetcher.ts | 126 ++++++++------ ...MetadataSidebarRedesign-visual.stories.tsx | 56 ------ 5 files changed, 244 insertions(+), 112 deletions(-) create mode 100644 src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-taxonomyPicker.test.tsx diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index 6de00f8a4e..da9f472e4e 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -387,6 +387,9 @@ function MetadataSidebarRedesign({ {editingTemplate && ( (() => null); +jest.mock('../MetadataInstanceEditor', () => ({ + __esModule: true, + default: (props: MetadataInstanceEditorProps) => mockEditor(props), +})); + +const mockUseSidebarMetadataFetcher = useSidebarMetadataFetcher as jest.MockedFunction< + typeof useSidebarMetadataFetcher +>; +const mockUseMetadataFieldSelection = useMetadataFieldSelection as jest.MockedFunction< + typeof useMetadataFieldSelection +>; + +const mockFile = { id: '123', permissions: { can_upload: true } }; +const mockTemplates: MetadataTemplate[] = [ + { + id: 'metadata_template_custom_1', + scope: 'global', + templateKey: 'properties', + hidden: false, + fields: [], + type: 'metadata_template', + }, +]; + +const taxonomyTemplateInstance = { + canEdit: true, + displayName: 'Taxonomy Template', + fields: [ + { + id: 'field-1', + type: 'taxonomy', + key: 'geography', + displayName: 'Geography', + hidden: false, + levels: [ + { level: 1, displayName: 'Region', description: '' }, + { level: 2, displayName: 'Country', description: '' }, + ], + optionsRules: { selectableLevels: [1, 2] }, + }, + ], + hidden: false, + id: 'taxonomy_instance', + scope: 'global', + templateKey: 'taxonomyTemplate', + type: 'metadata_template', +} as unknown as MetadataTemplateInstance; + +const api = { options: { token: jest.fn().mockResolvedValue({ read: 'r', write: 'w' }) } }; + +const renderSidebar = (features: Record = {}) => { + const history = createMemoryHistory({ initialEntries: ['/metadata'] }); + const routerProps = { + history, + location: history.location, + match: { params: {}, isExact: true, path: '/metadata', url: '/metadata' }, + } as unknown as RouteComponentProps; + + const props = { + api, + elementId: 'element-1', + fileExtension: 'pdf', + fileId: 'file-id-1', + filteredTemplateIds: [], + getPreview: jest.fn().mockReturnValue({}), + isFeatureEnabled: true, + onError: jest.fn(), + onSuccess: jest.fn(), + ...routerProps, + } satisfies MetadataSidebarRedesignProps; + + return render(, { wrapperProps: { features } }); +}; + +describe('MetadataSidebarRedesign taxonomy picker wiring', () => { + beforeEach(() => { + mockEditor.mockClear(); + + mockUseMetadataFieldSelection.mockReturnValue({ + selectedMetadataFieldId: null, + handleSelectMetadataField: jest.fn(), + }); + + mockUseSidebarMetadataFetcher.mockReturnValue({ + clearExtractError: jest.fn(), + extractSuggestions: jest.fn(), + handleCreateMetadataInstance: jest.fn(), + handleDeleteMetadataInstance: jest.fn(), + handleUpdateMetadataInstance: jest.fn(), + templateInstances: [taxonomyTemplateInstance], + templates: mockTemplates, + errorMessage: null, + status: STATUS.SUCCESS, + file: mockFile, + extractErrorCode: null, + }); + }); + + const enterEditMode = async () => { + const editButton = await screen.findByRole('button', { name: /Edit Taxonomy Template/i }); + await userEvent.click(editButton); + }; + + const lastEditorProps = (): MetadataInstanceEditorProps => { + const { calls } = mockEditor.mock; + const [firstArg] = calls[calls.length - 1]; + return firstArg; + }; + + test('passes createTaxonomyItemsService to the editor when the picker flag is on', async () => { + renderSidebar({ 'metadata.taxonomyPicker.enabled': true }); + await enterEditMode(); + + const editorProps = lastEditorProps(); + expect(editorProps.isMetadataTaxonomyPickerEnabled).toBe(true); + expect(editorProps.createTaxonomyItemsService).toEqual(expect.any(Function)); + }); + + test('omits createTaxonomyItemsService when the picker flag is off', async () => { + renderSidebar({ 'metadata.taxonomyPicker.enabled': false }); + await enterEditMode(); + + const editorProps = lastEditorProps(); + expect(editorProps.isMetadataTaxonomyPickerEnabled).toBe(false); + expect(editorProps.createTaxonomyItemsService).toBeUndefined(); + }); + + test('service factory resolves field config from the editing template', async () => { + renderSidebar({ 'metadata.taxonomyPicker.enabled': true }); + await enterEditMode(); + + const editorProps = lastEditorProps(); + // The service factory calls resolveField(templateKey, fieldKey); creating + // the service must not throw when the taxonomy field is in the template. + expect(() => + editorProps.createTaxonomyItemsService?.({ + scope: 'global', + templateKey: 'taxonomyTemplate', + fieldKey: 'geography', + }), + ).not.toThrow(); + }); +}); diff --git a/src/elements/content-sidebar/__tests__/metadataTaxonomyFetcher.test.ts b/src/elements/content-sidebar/__tests__/metadataTaxonomyFetcher.test.ts index 947c4e48f4..bcf62a595f 100644 --- a/src/elements/content-sidebar/__tests__/metadataTaxonomyFetcher.test.ts +++ b/src/elements/content-sidebar/__tests__/metadataTaxonomyFetcher.test.ts @@ -301,7 +301,10 @@ describe('metadataTaxonomyFetcher', () => { }); }); -// TODO: delete whole section during clean up as for now we have to handle both new and old naming convention +// The backend `taxonomy-node` endpoint is migrating from snake_case (`display_name`) to +// camelCase (`displayName`). Until the migration completes we validate both response shapes +// side-by-side; delete this "(old keys naming convention)" block once the snake_case +// response is fully retired. describe('metadataTaxonomyNodeAncestorsFetcher (old keys naming convention)', () => { const fileID = '12345'; const scope = 'global'; diff --git a/src/elements/content-sidebar/fetchers/metadataTaxonomyFetcher.ts b/src/elements/content-sidebar/fetchers/metadataTaxonomyFetcher.ts index a4e35254b0..ba2d2001b8 100644 --- a/src/elements/content-sidebar/fetchers/metadataTaxonomyFetcher.ts +++ b/src/elements/content-sidebar/fetchers/metadataTaxonomyFetcher.ts @@ -115,52 +115,84 @@ export const metadataTaxonomyNodeAncestorsFetcher = async ( return hydratedLevels; }; -// Upstream sources mix snake_case (/options) and camelCase (taxonomy-node), so read both. -const getDisplayName = (option: MetadataOptionEntry): string => option.display_name || option.displayName || ''; +/** + * Shape of a taxonomy node as it arrives from the backend. + * + * The `/options` endpoint currently emits snake_case (`display_name`, `has_children`) + * while the `taxonomy-node` endpoint has migrated to camelCase (`displayName`, + * `hasChildren`). Until both endpoints converge on camelCase we accept either + * spelling on every field. See `getDisplayName` / `getHasChildren` for the + * normalization logic. + * + * This local type intentionally supersedes the untyped Flow `MetadataOptionEntry` + * for taxonomy code — it documents which fields we actually consume. + */ +type TaxonomyEntry = { + id: string; + // API may return level as number ('1') or string ('"1"'); callers coerce. + level: number | string; + display_name?: string; + displayName?: string; + has_children?: boolean; + hasChildren?: boolean; + selectable?: boolean; + ancestors?: Array; +}; + +type TaxonomyEntryAncestor = { + id: string; + level: number | string; + display_name?: string; + displayName?: string; +}; -const mapAncestors = (ancestors: MetadataOptionEntry['ancestors']): TaxonomyAncestor[] => { +const getDisplayName = (entry: TaxonomyEntry | TaxonomyEntryAncestor): string => + entry.display_name || entry.displayName || ''; + +const mapAncestors = (ancestors: TaxonomyEntry['ancestors']): TaxonomyAncestor[] => { const list = ancestors ?? []; // Bare-string ancestor ids lack the data needed to render a breadcrumb. return list - .filter(ancestor => !!ancestor && typeof ancestor === 'object') + .filter((ancestor): ancestor is TaxonomyEntryAncestor => !!ancestor && typeof ancestor === 'object') .map(ancestor => ({ id: ancestor.id, - displayName: ancestor.display_name || ancestor.displayName || '', + displayName: getDisplayName(ancestor), level: Number(ancestor.level), })); }; -// Prefers backend `has_children`; falls back to depth. `forceLeaf` disables drill-down. -const getHasChildren = (option: MetadataOptionEntry, maxLevel?: number, forceLeaf?: boolean): boolean => { - if (forceLeaf) { +/** + * Prefers the backend `has_children` / `hasChildren` flag when present, otherwise + * infers from taxonomy depth. In single-level picker mode every node is treated + * as a leaf so the drill-down chevron never appears. + */ +const getHasChildren = (entry: TaxonomyEntry, maxLevel: number, isSingleLevelMode: boolean): boolean => { + if (isSingleLevelMode) { return false; } - if (typeof option.has_children === 'boolean') { - return option.has_children; + if (typeof entry.has_children === 'boolean') { + return entry.has_children; } - if (typeof option.hasChildren === 'boolean') { - return option.hasChildren; + if (typeof entry.hasChildren === 'boolean') { + return entry.hasChildren; } - return maxLevel != null ? Number(option.level) < maxLevel : false; + return maxLevel > 0 ? Number(entry.level) < maxLevel : false; }; // Always producing `ancestors` lets one mapper satisfy both browse (optional) and search (required). const mapOption = ( - option: MetadataOptionEntry, - maxLevel?: number, - forceLeaf?: boolean, + entry: TaxonomyEntry, + maxLevel: number, + isSingleLevelMode: boolean, ): TaxonomyNode & TaxonomySearchResult => ({ - id: option.id, - displayName: getDisplayName(option), - level: Number(option.level), - selectable: !!option.selectable, - hasChildren: getHasChildren(option, maxLevel, forceLeaf), - ancestors: mapAncestors(option.ancestors), + id: entry.id, + displayName: getDisplayName(entry), + level: Number(entry.level), + selectable: !!entry.selectable, + hasChildren: getHasChildren(entry, maxLevel, isSingleLevelMode), + ancestors: mapAncestors(entry.ancestors), }); -const getMaxLevel = (levels?: Level[]): number | undefined => - levels && levels.length > 0 ? Math.max(...levels.map(({ level }) => level)) : undefined; - export type TaxonomyFieldConfig = { levels?: Level[]; selectableLevels?: number[]; @@ -180,31 +212,25 @@ export const createTaxonomyItemsService = resolveField?: (templateKey: string, fieldKey: string) => TaxonomyFieldConfig | undefined, ): CreateTaxonomyItemsService => ({ scope, templateKey, fieldKey }): TaxonomyItemsService => { - const fieldConfig = resolveField?.(templateKey, fieldKey); - const maxLevel = getMaxLevel(fieldConfig?.levels); - const selectableLevels = fieldConfig?.selectableLevels; - const singleSelectableLevel = - selectableLevels && selectableLevels.length === 1 ? Number(selectableLevels[0]) : undefined; - const isSingleLevelPickerMode = singleSelectableLevel != null; + // Defaults keep the rest of the logic free of `?.` / `!= null` noise. + const { levels = [], selectableLevels = [] } = resolveField?.(templateKey, fieldKey) ?? {}; + const maxLevel = levels.length > 0 ? Math.max(...levels.map(({ level }) => level)) : 0; + const isSingleLevelMode = selectableLevels.length === 1; + const singleSelectableLevel = isSingleLevelMode ? Number(selectableLevels[0]) : undefined; // Multi-level taxonomies need a `level` filter or /options mixes branches. - const isMultiLevelTaxonomy = maxLevel != null && maxLevel > 1; + const isMultiLevelTaxonomy = maxLevel > 1; // Tracks levels of returned nodes so drill-down can request `parentLevel + 1`. const nodeLevelById = new Map(); - const rememberLevels = (entries: MetadataOptionEntry[] | undefined): void => { - if (!entries) { - return; - } + const rememberLevels = (entries: TaxonomyEntry[]): void => { for (const entry of entries) { if (entry?.id != null && entry.level != null) { nodeLevelById.set(String(entry.id), Number(entry.level)); } - if (Array.isArray(entry?.ancestors)) { - for (const ancestor of entry.ancestors) { - if (ancestor && typeof ancestor === 'object' && ancestor.id != null && ancestor.level != null) { - nodeLevelById.set(String(ancestor.id), Number(ancestor.level)); - } + for (const ancestor of entry?.ancestors ?? []) { + if (ancestor && typeof ancestor === 'object' && ancestor.id != null && ancestor.level != null) { + nodeLevelById.set(String(ancestor.id), Number(ancestor.level)); } } } @@ -215,7 +241,7 @@ export const createTaxonomyItemsService = return singleSelectableLevel ?? 1; } const parentLevel = nodeLevelById.get(parentId); - return parentLevel != null ? parentLevel + 1 : 1; + return parentLevel !== undefined ? parentLevel + 1 : 1; }; const getNodes = async ( @@ -223,9 +249,9 @@ export const createTaxonomyItemsService = { marker, signal }: FetchParams, ): Promise> => { // Single-level picker is flat — defensively ignore drill-down. - const effectiveParentId = isSingleLevelPickerMode ? null : parentId; + const effectiveParentId = isSingleLevelMode ? null : parentId; const childLevel = getChildLevel(effectiveParentId); - const shouldFilterByLevel = isMultiLevelTaxonomy || isSingleLevelPickerMode; + const shouldFilterByLevel = isMultiLevelTaxonomy || isSingleLevelMode; const response = await api .getMetadataAPI(false) .getMetadataOptions(fileId, scope, templateKey, fieldKey, childLevel, { @@ -235,13 +261,11 @@ export const createTaxonomyItemsService = signal, }); - const entries = response.entries ?? []; + const entries: TaxonomyEntry[] = response.entries ?? []; rememberLevels(entries); return { - entries: entries.map((option: MetadataOptionEntry) => - mapOption(option, maxLevel, isSingleLevelPickerMode), - ), + entries: entries.map(entry => mapOption(entry, maxLevel, isSingleLevelMode)), next_marker: response.next_marker ?? undefined, }; }; @@ -260,13 +284,11 @@ export const createTaxonomyItemsService = signal, }); - const entries = response.entries ?? []; + const entries: TaxonomyEntry[] = response.entries ?? []; rememberLevels(entries); return { - entries: entries.map((option: MetadataOptionEntry) => - mapOption(option, maxLevel, isSingleLevelPickerMode), - ), + entries: entries.map(entry => mapOption(entry, maxLevel, isSingleLevelMode)), next_marker: response.next_marker ?? undefined, }; }; diff --git a/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx b/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx index c0c6c2211c..d03c0cf47f 100644 --- a/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx +++ b/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx @@ -894,62 +894,6 @@ export const EditMultilevelTaxonomy: StoryObj = }, }; -const taxonomyPickerFeatures = { - ...mockFeatures, - 'metadata.multilevelTaxonomy.enabled': true, - 'metadata.taxonomyPicker.enabled': true, -}; - -const openTaxonomyEditor = async (canvas: ReturnType) => { - await waitForLoadingToComplete(canvas); - const editButton = await canvas.findByRole('button', { name: 'Edit My Taxonomy' }); - await userEvent.click(editButton); -}; - -export const ViewMultilevelTaxonomyWithPicker: StoryObj = { - ...ViewMultilevelTaxonomy, - args: { - ...ViewMultilevelTaxonomy.args, - features: taxonomyPickerFeatures, - }, - play: async ({ canvasElement }) => { - await waitForLoadingToComplete(within(canvasElement)); - }, -}; - -export const EditMultilevelTaxonomyWithPicker: StoryObj = { - ...ViewMultilevelTaxonomy, - args: { - ...ViewMultilevelTaxonomy.args, - features: taxonomyPickerFeatures, - }, - play: async ({ canvasElement }) => { - await openTaxonomyEditor(within(canvasElement)); - }, -}; - -export const ViewSinglelevelTaxonomyWithPicker: StoryObj = { - ...ViewSinglelevelTaxonomy, - args: { - ...ViewSinglelevelTaxonomy.args, - features: taxonomyPickerFeatures, - }, - play: async ({ canvasElement }) => { - await waitForLoadingToComplete(within(canvasElement)); - }, -}; - -export const EditSinglelevelTaxonomyWithPicker: StoryObj = { - ...ViewSinglelevelTaxonomy, - args: { - ...ViewSinglelevelTaxonomy.args, - features: taxonomyPickerFeatures, - }, - play: async ({ canvasElement }) => { - await openTaxonomyEditor(within(canvasElement)); - }, -}; - export const EditSinglelevelTaxonomy: StoryObj = { ...ViewSinglelevelTaxonomy, play: async ({ canvasElement }) => {