diff --git a/package.json b/package.json index 4929fea20d..9fa89df588 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/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; @@ -362,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 0305741a15..bcf62a595f 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', @@ -291,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'; @@ -417,22 +430,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 +484,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 +534,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..ba2d2001b8 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,184 @@ export const metadataTaxonomyNodeAncestorsFetcher = async ( // Return the hydrated levels as an array return hydratedLevels; }; + +/** + * 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 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 is TaxonomyEntryAncestor => !!ancestor && typeof ancestor === 'object') + .map(ancestor => ({ + id: ancestor.id, + displayName: getDisplayName(ancestor), + level: Number(ancestor.level), + })); +}; + +/** + * 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 entry.has_children === 'boolean') { + return entry.has_children; + } + if (typeof entry.hasChildren === 'boolean') { + return entry.hasChildren; + } + return maxLevel > 0 ? Number(entry.level) < maxLevel : false; +}; + +// Always producing `ancestors` lets one mapper satisfy both browse (optional) and search (required). +const mapOption = ( + entry: TaxonomyEntry, + maxLevel: number, + isSingleLevelMode: boolean, +): TaxonomyNode & TaxonomySearchResult => ({ + id: entry.id, + displayName: getDisplayName(entry), + level: Number(entry.level), + selectable: !!entry.selectable, + hasChildren: getHasChildren(entry, maxLevel, isSingleLevelMode), + ancestors: mapAncestors(entry.ancestors), +}); + +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 => { + // 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 > 1; + + // Tracks levels of returned nodes so drill-down can request `parentLevel + 1`. + const nodeLevelById = new Map(); + + 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)); + } + 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 !== undefined ? parentLevel + 1 : 1; + }; + + const getNodes = async ( + parentId: string | null, + { marker, signal }: FetchParams, + ): Promise> => { + // Single-level picker is flat — defensively ignore drill-down. + const effectiveParentId = isSingleLevelMode ? null : parentId; + const childLevel = getChildLevel(effectiveParentId); + const shouldFilterByLevel = isMultiLevelTaxonomy || isSingleLevelMode; + const response = await api + .getMetadataAPI(false) + .getMetadataOptions(fileId, scope, templateKey, fieldKey, childLevel, { + ...(shouldFilterByLevel ? { level: childLevel } : {}), + ...(effectiveParentId ? { ancestorId: effectiveParentId } : {}), + ...(marker ? { marker } : {}), + signal, + }); + + const entries: TaxonomyEntry[] = response.entries ?? []; + rememberLevels(entries); + + return { + entries: entries.map(entry => mapOption(entry, maxLevel, isSingleLevelMode)), + 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: TaxonomyEntry[] = response.entries ?? []; + rememberLevels(entries); + + return { + entries: entries.map(entry => mapOption(entry, maxLevel, isSingleLevelMode)), + next_marker: response.next_marker ?? undefined, + }; + }; + + return { getNodes, searchNodes }; + }; diff --git a/yarn.lock b/yarn.lock index 25e15c3649..6e71ca03ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1178,20 +1178,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"