diff --git a/package.json b/package.json index ae9074ddce..d37e17c5fd 100644 --- a/package.json +++ b/package.json @@ -125,23 +125,23 @@ "@babel/template": "^7.24.7", "@babel/types": "^7.24.7", "@box/activity-feed": "^3.0.0", - "@box/blueprint-web": "^16.20.5", - "@box/blueprint-web-assets": "^5.7.12", - "@box/box-ai-agent-selector": "^1.39.21", + "@box/blueprint-web": "^17.2.4", + "@box/blueprint-web-assets": "^5.9.0", + "@box/box-ai-agent-selector": "^2.2.31", "@box/box-ai-content-answers": "^1.43.22", "@box/box-item-type-selector": "^1.39.21", "@box/cldr-data": "^34.2.0", "@box/collaboration-popover": "^2.2.21", - "@box/combobox-with-api": "^1.42.22", + "@box/combobox-with-api": "^2.2.34", "@box/content-field": "^1.40.23", "@box/copy-input": "^1.42.16", "@box/frontend": "^11.0.1", "@box/item-icon": "^3.2.0", "@box/languages": "^1.0.0", - "@box/metadata-editor": "^2.4.19", + "@box/metadata-editor": "^2.5.12", "@box/metadata-filter": "^1.80.23", - "@box/metadata-taxonomy-picker": "^3.3.1", - "@box/metadata-template-browser": "^2.1.7", + "@box/metadata-taxonomy-picker": "^3.3.14", + "@box/metadata-template-browser": "^2.3.14", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/readable-time": "^2.2.21", @@ -149,7 +149,7 @@ "@box/types": "^2.1.8", "@box/unified-share-modal": "^2.15.16", "@box/uploads-manager": "^2.4.0", - "@box/user-selector": "^2.2.22", + "@box/user-selector": "^3.0.10", "@cfaester/enzyme-adapter-react-18": "^0.8.0", "@chromatic-com/storybook": "^5.2.1", "@commitlint/cli": "^19.8.0", @@ -297,21 +297,21 @@ }, "peerDependencies": { "@box/activity-feed": "^3.0.0", - "@box/blueprint-web": "^16.20.5", - "@box/blueprint-web-assets": "^5.7.12", - "@box/box-ai-agent-selector": "^1.39.21", + "@box/blueprint-web": "^17.2.4", + "@box/blueprint-web-assets": "^5.9.0", + "@box/box-ai-agent-selector": "^2.2.31", "@box/box-ai-content-answers": "^1.43.22", "@box/box-item-type-selector": "^1.39.21", "@box/cldr-data": ">=34.2.0", "@box/collaboration-popover": "^2.2.21", - "@box/combobox-with-api": "^1.42.22", + "@box/combobox-with-api": "^2.2.34", "@box/content-field": "^1.40.23", "@box/copy-input": "^1.42.16", "@box/item-icon": "^3.2.0", - "@box/metadata-editor": "^2.2.11", + "@box/metadata-editor": "^2.5.12", "@box/metadata-filter": "^1.80.23", - "@box/metadata-taxonomy-picker": "^3.1.8", - "@box/metadata-template-browser": "^2.1.7", + "@box/metadata-taxonomy-picker": "^3.3.14", + "@box/metadata-template-browser": "^2.3.14", "@box/metadata-view": "^1.53.26", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/readable-time": "^2.2.21", @@ -319,7 +319,7 @@ "@box/types": "^2.1.8", "@box/unified-share-modal": "^2.15.16", "@box/uploads-manager": "^2.4.0", - "@box/user-selector": "^2.2.22", + "@box/user-selector": "^3.0.10", "@hapi/address": "^2.1.4", "@tanstack/react-virtual": "^3.13.12", "axios": "^0.33.0", diff --git a/src/common/types/metadata.js b/src/common/types/metadata.js index ba99410e39..79d6044cef 100644 --- a/src/common/types/metadata.js +++ b/src/common/types/metadata.js @@ -6,6 +6,7 @@ import { FIELD_TYPE_MULTISELECT, FIELD_TYPE_STRING, FIELD_TYPE_TAXONOMY, + FIELD_TYPE_USER, } from '../../features/metadata-instance-fields/constants'; import type { SkillCards } from './skills'; @@ -15,7 +16,8 @@ type MetadataFieldType = | typeof FIELD_TYPE_FLOAT | typeof FIELD_TYPE_MULTISELECT | typeof FIELD_TYPE_STRING - | typeof FIELD_TYPE_TAXONOMY; + | typeof FIELD_TYPE_TAXONOMY + | typeof FIELD_TYPE_USER; type MetadataTemplateFieldOption = { id?: string, diff --git a/src/elements/content-sidebar/MetadataInstanceEditor.tsx b/src/elements/content-sidebar/MetadataInstanceEditor.tsx index 7d870531ed..a2fe8713a3 100644 --- a/src/elements/content-sidebar/MetadataInstanceEditor.tsx +++ b/src/elements/content-sidebar/MetadataInstanceEditor.tsx @@ -9,6 +9,10 @@ import { CreateTaxonomyItemsService, TaxonomyOptionsFetcher, } from '@box/metadata-editor/lib/components/metadata-editor-fields/components/metadata-taxonomy-field/types.js'; +import type { + FetchAvatarUrls, + FetchUsers, +} from '@box/metadata-editor/lib/components/metadata-editor-fields/components/metadata-user-field/types.js'; import React, { useContext } from 'react'; import PreviewContext, { type PreviewContextType } from '../content-preview/PreviewContext'; import { @@ -20,6 +24,8 @@ import { export interface MetadataInstanceEditorProps { areAiSuggestionsAvailable: boolean; errorCode?: ERROR_CODE_METADATA_AUTOFILL_TIMEOUT | ERROR_CODE_METADATA_PRECONDITION_FAILED | ERROR_CODE_UNKNOWN; + fetchAvatarUrls?: FetchAvatarUrls; + fetchUsers?: FetchUsers; isBetaLanguageEnabled: boolean; isBoxAiSuggestionsEnabled: boolean; isDeleteButtonDisabled: boolean; @@ -27,6 +33,7 @@ export interface MetadataInstanceEditorProps { isLargeFile: boolean; isMetadataMultiLevelTaxonomyFieldEnabled: boolean; isMetadataTaxonomyPickerEnabled?: boolean; + isMetadataUserFieldEnabled?: boolean; isUnsavedChangesModalOpen: boolean; onCancel: () => void; onDelete: (metadataInstance: MetadataTemplateInstance) => void; @@ -49,6 +56,8 @@ export interface MetadataInstanceEditorProps { const MetadataInstanceEditor: React.FC = ({ areAiSuggestionsAvailable, errorCode, + fetchAvatarUrls, + fetchUsers, isBetaLanguageEnabled, isBoxAiSuggestionsEnabled, isDeleteButtonDisabled, @@ -56,6 +65,7 @@ const MetadataInstanceEditor: React.FC = ({ isLargeFile, isMetadataMultiLevelTaxonomyFieldEnabled, isMetadataTaxonomyPickerEnabled = false, + isMetadataUserFieldEnabled = false, isUnsavedChangesModalOpen, onCancel, onDelete, @@ -82,6 +92,9 @@ const MetadataInstanceEditor: React.FC = ({ // TODO investigate if this property should be optional and by default false isMultilevelTaxonomyFieldEnabled={isMetadataMultiLevelTaxonomyFieldEnabled} isTaxonomyPickerEnabled={isMetadataTaxonomyPickerEnabled} + isUserFieldEnabled={isMetadataUserFieldEnabled} + fetchAvatarUrls={fetchAvatarUrls} + fetchUsers={fetchUsers} createTaxonomyItemsService={createTaxonomyItemsService} areAiSuggestionsAvailable={areAiSuggestionsAvailable} errorCode={errorCode} diff --git a/src/elements/content-sidebar/MetadataSidebar.js b/src/elements/content-sidebar/MetadataSidebar.js index 905430fac7..fa3f7ee4d3 100644 --- a/src/elements/content-sidebar/MetadataSidebar.js +++ b/src/elements/content-sidebar/MetadataSidebar.js @@ -49,6 +49,10 @@ type ExternalProps = { onWarningModalDiscard?: () => void, onWarningModalClose?: () => void, trackEvent?: (eventName: string, data?: { [string]: any }) => void, + // Redesign path only — optional host overrides for the metadata user field fetchers. + // Canonical types (FetchUsers / FetchAvatarUrls) live in @box/metadata-editor (TypeScript). + fetchUsers?: (inputValue: string) => Promise>, + fetchAvatarUrls?: (userContacts: Array) => Promise<{ [key: string]: string }>, }; type PropsWithoutContext = { diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index da9f472e4e..7a9bb1f8e6 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -19,6 +19,10 @@ import { type MetadataTemplateInstance, } from '@box/metadata-editor'; import { TreeQueryInput } from '@box/combobox-with-api'; +import type { + FetchAvatarUrls, + FetchUsers, +} from '@box/metadata-editor/lib/components/metadata-editor-fields/components/metadata-user-field/types.js'; import type { GetPreviewForMetadataReturnType } from './types/BoxAISidebarTypes'; import API from '../../api'; @@ -60,6 +64,10 @@ mark(MARK_NAME_JS_READY); export interface ExternalProps { isFeatureEnabled: boolean; getStructuredTextRep?: (fileId: string, accessToken: string) => Promise; + /** Custom user/group search fetcher (e.g. a session-authenticated contacts endpoint). */ + fetchUsers?: FetchUsers; + /** Custom avatar URL resolver. */ + fetchAvatarUrls?: FetchAvatarUrls; } interface PropsWithoutContext extends ExternalProps { @@ -102,6 +110,8 @@ function MetadataSidebarRedesign({ elementId, fileExtension, fileId, + fetchAvatarUrls, + fetchUsers, filteredTemplateIds = [], getPreview, history, @@ -121,6 +131,7 @@ function MetadataSidebarRedesign({ const isBetaLanguageEnabled: boolean = useFeatureEnabled('metadata.betaLanguage.enabled'); const isMetadataMultiLevelTaxonomyFieldEnabled: boolean = useFeatureEnabled('metadata.multilevelTaxonomy.enabled'); const isMetadataTaxonomyPickerEnabled: boolean = useFeatureEnabled('metadata.taxonomyPicker.enabled'); + const isMetadataUserFieldEnabled: boolean = useFeatureEnabled('metadata.userField.enabled'); const isAdvancedExtractAgentEnabled: boolean = useFeatureEnabled('metadata.extractAdvancedAgents.enabled'); const isDeleteConfirmationModalCheckboxEnabled: boolean = useFeatureEnabled( 'metadata.deleteConfirmationModalCheckbox.enabled', @@ -391,6 +402,8 @@ function MetadataSidebarRedesign({ isMetadataTaxonomyPickerEnabled ? taxonomyItemsServiceCreator : undefined } errorCode={extractErrorCode} + fetchAvatarUrls={fetchAvatarUrls} + fetchUsers={fetchUsers} isBetaLanguageEnabled={isBetaLanguageEnabled} isBoxAiSuggestionsEnabled={isBoxAiSuggestionsEnabled} isDeleteButtonDisabled={isDeleteButtonDisabled} @@ -398,6 +411,7 @@ function MetadataSidebarRedesign({ isLargeFile={isLargeFile} isMetadataMultiLevelTaxonomyFieldEnabled={isMetadataMultiLevelTaxonomyFieldEnabled} isMetadataTaxonomyPickerEnabled={isMetadataTaxonomyPickerEnabled} + isMetadataUserFieldEnabled={isMetadataUserFieldEnabled} isUnsavedChangesModalOpen={isUnsavedChangesModalOpen} onCancel={handleCancel} onDelete={handleDeleteInstance} diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx index 2e8a9b7b1f..2031b7f480 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx @@ -11,6 +11,22 @@ import { } from '../MetadataSidebarRedesign'; import useSidebarMetadataFetcher, { STATUS } from '../hooks/useSidebarMetadataFetcher'; import useMetadataFieldSelection from '../hooks/useMetadataFieldSelection'; +import type { MetadataInstanceEditorProps } from '../MetadataInstanceEditor'; + +// Records props passed to the editor so wiring tests can assert on them +// without replacing the real editor used by the rest of this suite. +const mockEditor = jest.fn(() => null); +jest.mock('../MetadataInstanceEditor', () => { + const actual = jest.requireActual('../MetadataInstanceEditor') as typeof import('../MetadataInstanceEditor'); + + return { + __esModule: true, + default: (props: MetadataInstanceEditorProps) => { + mockEditor(props); + return actual.default(props); + }, + }; +}); jest.mock('../hooks/useSidebarMetadataFetcher'); const mockUseSidebarMetadataFetcher = useSidebarMetadataFetcher as jest.MockedFunction< @@ -655,6 +671,35 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { expect(mockUseMetadataFieldSelection).toHaveBeenCalledWith(getPreview); }); + test('passes host-provided fetchers to the editor when the user field flag is on', async () => { + const fetchUsers = jest.fn(); + const fetchAvatarUrls = jest.fn(); + + mockUseSidebarMetadataFetcher.mockReturnValue({ + clearExtractError: jest.fn(), + extractSuggestions: jest.fn(), + handleCreateMetadataInstance: jest.fn(), + handleDeleteMetadataInstance: jest.fn(), + handleUpdateMetadataInstance: jest.fn(), + templateInstances: [mockVisibleTemplateInstance], + templates: mockTemplates, + errorMessage: null, + status: STATUS.SUCCESS, + file: mockFile, + extractErrorCode: null, + }); + + renderComponent({ fetchAvatarUrls, fetchUsers }, { 'metadata.userField.enabled': true }); + + await userEvent.click(screen.getByRole('button', { name: 'Edit Visible Template' })); + + const { calls } = mockEditor.mock; + const [editorProps] = calls[calls.length - 1]; + expect(editorProps.isMetadataUserFieldEnabled).toBe(true); + expect(editorProps.fetchUsers).toBe(fetchUsers); + expect(editorProps.fetchAvatarUrls).toBe(fetchAvatarUrls); + }); + describe('navigation blocking', () => { const navBlockFeatures = { 'metadata.confidenceScore.enabled': true }; diff --git a/src/elements/content-sidebar/stories/__mocks__/UserFieldMocks.ts b/src/elements/content-sidebar/stories/__mocks__/UserFieldMocks.ts new file mode 100644 index 0000000000..645d4d1b87 --- /dev/null +++ b/src/elements/content-sidebar/stories/__mocks__/UserFieldMocks.ts @@ -0,0 +1,170 @@ +import { http, HttpResponse } from 'msw'; +import type { HttpHandler } from 'msw'; + +import { DEFAULT_HOSTNAME_API } from '../../../../constants'; +import { fileIdWithMetadata, mockFileRequest, mockGlobalMetadataTemplates } from './MetadataSidebarRedesignedMocks'; +import { mockUserRequest } from '../../../common/__mocks__/mockRequests'; + +const apiV2Path = `${DEFAULT_HOSTNAME_API}/2.0`; + +export const mockEnterpriseUsers = { + url: `${apiV2Path}/users`, + entries: [ + { + type: 'user', + id: '1', + name: 'Alice Wong', + email: 'awong@example.com', + login: 'awong@example.com', + }, + { + type: 'user', + id: '2', + name: 'Bob Smith', + email: 'bsmith@example.com', + login: 'bsmith@example.com', + }, + { + type: 'user', + id: '3', + name: 'Charlie Nguyen', + email: 'cnguyen@example.com', + login: 'cnguyen@example.com', + }, + ], +}; + +export const mockEnterpriseGroups = { + url: `${apiV2Path}/groups`, + entries: [ + { + type: 'group', + id: '100', + name: 'Design Team', + }, + { + type: 'group', + id: '101', + name: 'Engineering Team', + }, + ], +}; + +export const mockMetadataTemplatesWithUserField = { + url: `${apiV2Path}/metadata_templates/enterprise`, + response: { + limit: 1000, + entries: [ + { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + type: 'metadata_template', + templateKey: 'userTemplate', + scope: 'enterprise_173733877', + displayName: 'User Template', + hidden: false, + copyInstanceOnItemCopy: false, + fields: [ + { + id: 'owner-field-id', + type: 'user', + key: 'owner', + displayName: 'Owner', + hidden: false, + description: 'Assign an owner to this file', + }, + ], + }, + ], + next_marker: null, + prev_marker: null, + }, +}; + +export const mockMetadataInstancesWithUserField = { + url: `${apiV2Path}/files/${fileIdWithMetadata}/metadata`, + response: { + entries: [ + { + $id: 'user-field-instance-id', + $version: 1, + $type: 'userTemplate-453ffc2f-bf5a-464c-a004-476a4eac20fd', + $parent: `file_${fileIdWithMetadata}`, + $typeVersion: 1, + $template: 'userTemplate', + $scope: 'enterprise_173733877', + $templateKey: 'userTemplate', + // Contact shape the picker/list already understand. Real instance + // values will be ids once the metadata API contract lands. + owner: [ + { + email: 'awong@example.com', + id: 1, + name: 'Alice Wong', + type: 'user' as const, + value: '1', + }, + ], + $canEdit: true, + }, + ], + limit: 100, + }, +}; + +const filterEntries = ( + entries: T[], + filterTerm: string, +): T[] => { + if (!filterTerm) { + return entries; + } + + const normalizedFilterTerm = filterTerm.toLowerCase(); + + return entries.filter(entry => { + const nameMatch = entry.name.toLowerCase().includes(normalizedFilterTerm); + const emailMatch = (entry.email ?? entry.login ?? '').toLowerCase().includes(normalizedFilterTerm); + + return nameMatch || emailMatch; + }); +}; + +export const userFieldMockHandlers: HttpHandler[] = [ + http.get(mockUserRequest.url, () => { + return HttpResponse.json(mockUserRequest.response); + }), + http.get(mockFileRequest.url, () => { + return HttpResponse.json(mockFileRequest.response); + }), + http.get(mockGlobalMetadataTemplates.url, () => { + return HttpResponse.json(mockGlobalMetadataTemplates.response); + }), + http.get(mockMetadataInstancesWithUserField.url, () => { + return HttpResponse.json(mockMetadataInstancesWithUserField.response); + }), + http.get(mockEnterpriseUsers.url, ({ request }) => { + const url = new URL(request.url); + // `/users` must not swallow `/users/me` (current-user fetch). + if (url.pathname.endsWith('/users/me')) { + return HttpResponse.json(mockUserRequest.response); + } + + const filterTerm = url.searchParams.get('filter_term') ?? ''; + + return HttpResponse.json({ + entries: filterEntries(mockEnterpriseUsers.entries, filterTerm), + limit: 100, + next_marker: null, + }); + }), + http.get(mockEnterpriseGroups.url, ({ request }) => { + const url = new URL(request.url); + const filterTerm = url.searchParams.get('filter_term') ?? ''; + + return HttpResponse.json({ + entries: filterEntries(mockEnterpriseGroups.entries, filterTerm), + limit: 100, + next_marker: null, + }); + }), +]; diff --git a/src/features/metadata-instance-fields/MetadataField.js b/src/features/metadata-instance-fields/MetadataField.js index 8705f4cb57..51e5c0021e 100644 --- a/src/features/metadata-instance-fields/MetadataField.js +++ b/src/features/metadata-instance-fields/MetadataField.js @@ -22,6 +22,7 @@ import { FIELD_TYPE_DATE, FIELD_TYPE_MULTISELECT, FIELD_TYPE_TAXONOMY, + FIELD_TYPE_USER, } from './constants'; type Props = { @@ -173,7 +174,15 @@ const MetadataField = ({ type={type} /> ); - + case FIELD_TYPE_USER: + return ( + + ); default: return ( diff --git a/src/features/metadata-instance-fields/__tests__/MetadataField.test.js b/src/features/metadata-instance-fields/__tests__/MetadataField.test.js index cb10421913..2883d4f96e 100644 --- a/src/features/metadata-instance-fields/__tests__/MetadataField.test.js +++ b/src/features/metadata-instance-fields/__tests__/MetadataField.test.js @@ -39,6 +39,12 @@ describe('features/metadata-instance-editor/fields/MetadataField', () => { ); expect(wrapper).toMatchSnapshot(); }); + test('should correctly render a user field - for the time being, in read-only mode', () => { + const wrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); test('should correctly render a float field', () => { const wrapper = shallow( , diff --git a/src/features/metadata-instance-fields/__tests__/__snapshots__/MetadataField.test.js.snap b/src/features/metadata-instance-fields/__tests__/__snapshots__/MetadataField.test.js.snap index adbb05e8a0..dd628ba4df 100644 --- a/src/features/metadata-instance-fields/__tests__/__snapshots__/MetadataField.test.js.snap +++ b/src/features/metadata-instance-fields/__tests__/__snapshots__/MetadataField.test.js.snap @@ -44,6 +44,13 @@ exports[`features/metadata-instance-editor/fields/MetadataField should correctly /> `; +exports[`features/metadata-instance-editor/fields/MetadataField should correctly render a user field - for the time being, in read-only mode 1`] = ` + +`; + exports[`features/metadata-instance-editor/fields/MetadataField should correctly render a text field 1`] = `