From f026fa1346b6a2e228203c223e410eac5f4b2ce2 Mon Sep 17 00:00:00 2001 From: Maciej Alfut Date: Fri, 21 Aug 2026 15:35:34 +0200 Subject: [PATCH] feat(content-sidebar): wire metadata user field into redesign sidebar --- package.json | 32 +-- src/common/types/metadata.js | 4 +- .../MetadataInstanceEditor.tsx | 13 ++ .../content-sidebar/MetadataSidebar.js | 4 + .../MetadataSidebarRedesign.tsx | 22 ++ .../MetadataInstanceEditor-userField.test.tsx | 82 ++++++++ ...MetadataSidebarRedesign-userField.test.tsx | 199 ++++++++++++++++++ .../__tests__/metadataUserFetcher.test.ts | 181 ++++++++++++++++ .../fetchers/metadataUserFetcher.ts | 85 ++++++++ .../MetadataSidebarRedesign.stories.tsx | 46 +++- .../stories/__mocks__/UserFieldMocks.ts | 170 +++++++++++++++ .../metadata-instance-fields/MetadataField.js | 11 +- .../__tests__/MetadataField.test.js | 6 + .../__snapshots__/MetadataField.test.js.snap | 7 + .../metadata-instance-fields/constants.js | 1 + yarn.lock | 76 +++---- 16 files changed, 882 insertions(+), 57 deletions(-) create mode 100644 src/elements/content-sidebar/__tests__/MetadataInstanceEditor-userField.test.tsx create mode 100644 src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-userField.test.tsx create mode 100644 src/elements/content-sidebar/__tests__/metadataUserFetcher.test.ts create mode 100644 src/elements/content-sidebar/fetchers/metadataUserFetcher.ts create mode 100644 src/elements/content-sidebar/stories/__mocks__/UserFieldMocks.ts 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..a28a54c231 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'; @@ -49,6 +53,7 @@ import { metadataTaxonomyNodeAncestorsFetcher, type TaxonomyFieldConfig, } from './fetchers/metadataTaxonomyFetcher'; +import { createFetchAvatarUrls, createFetchUsers } from './fetchers/metadataUserFetcher'; import { useMetadataSidebarFilteredTemplates } from './hooks/useMetadataSidebarFilteredTemplates'; import useMetadataFieldSelection from './hooks/useMetadataFieldSelection'; import useMetadataSidebarUnsavedChangesGuard from './hooks/useMetadataSidebarUnsavedChangesGuard'; @@ -60,6 +65,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); defaults to enterprise /users + /groups search. */ + fetchUsers?: FetchUsers; + /** Custom avatar URL resolver; defaults to /users/:id/avatar with an access token. */ + fetchAvatarUrls?: FetchAvatarUrls; } interface PropsWithoutContext extends ExternalProps { @@ -102,6 +111,8 @@ function MetadataSidebarRedesign({ elementId, fileExtension, fileId, + fetchAvatarUrls: fetchAvatarUrlsOverride, + fetchUsers: fetchUsersOverride, filteredTemplateIds = [], getPreview, history, @@ -121,6 +132,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', @@ -354,6 +366,13 @@ function MetadataSidebarRedesign({ [api, fileId, resolveTaxonomyFieldConfig], ); + // Hosts can inject their own user-field fetchers (e.g. a session-authenticated contacts + // endpoint); without overrides the sidebar uses the default BUIE API implementations. + const defaultFetchUsers = useMemo(() => createFetchUsers(api, fileId), [api, fileId]); + const defaultFetchAvatarUrls = useMemo(() => createFetchAvatarUrls(api, fileId), [api, fileId]); + const fetchUsers = fetchUsersOverride ?? defaultFetchUsers; + const fetchAvatarUrls = fetchAvatarUrlsOverride ?? defaultFetchAvatarUrls; + useEffect(() => { if (createSessionRequest && fileId && !isSessionInitiated.current) { isSessionInitiated.current = true; @@ -391,6 +410,8 @@ function MetadataSidebarRedesign({ isMetadataTaxonomyPickerEnabled ? taxonomyItemsServiceCreator : undefined } errorCode={extractErrorCode} + fetchAvatarUrls={fetchAvatarUrls} + fetchUsers={fetchUsers} isBetaLanguageEnabled={isBetaLanguageEnabled} isBoxAiSuggestionsEnabled={isBoxAiSuggestionsEnabled} isDeleteButtonDisabled={isDeleteButtonDisabled} @@ -398,6 +419,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__/MetadataInstanceEditor-userField.test.tsx b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor-userField.test.tsx new file mode 100644 index 0000000000..24bbd7b1d5 --- /dev/null +++ b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor-userField.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import type { MetadataTemplateInstance } from '@box/metadata-editor'; +import { render } from '../../../test-utils/testing-library'; +import MetadataInstanceEditor, { type MetadataInstanceEditorProps } from '../MetadataInstanceEditor'; + +const mockMetadataInstanceForm = jest.fn]>(() => null); + +// Stubbing MetadataInstanceForm lets us inspect the props handed to @box/metadata-editor +// without rendering the real (possibly yarn-linked) package form tree. +jest.mock('@box/metadata-editor', () => ({ + __esModule: true, + MetadataInstanceForm: (props: Record) => mockMetadataInstanceForm(props), +})); + +describe('MetadataInstanceEditor user field wiring', () => { + const template = { + id: 'template-id', + displayName: 'Template Name', + fields: [], + scope: 'global', + templateKey: 'customTemplate', + type: 'metadata_template', + hidden: false, + canEdit: true, + } as unknown as MetadataTemplateInstance; + + const defaultProps: MetadataInstanceEditorProps = { + areAiSuggestionsAvailable: true, + isBetaLanguageEnabled: false, + isBoxAiSuggestionsEnabled: true, + isDeleteButtonDisabled: false, + isDeleteConfirmationModalCheckboxEnabled: false, + isLargeFile: false, + isMetadataMultiLevelTaxonomyFieldEnabled: false, + isUnsavedChangesModalOpen: false, + onCancel: jest.fn(), + onDelete: jest.fn(), + onDiscardUnsavedChanges: jest.fn(), + onSubmit: jest.fn(), + setIsUnsavedChangesModalOpen: jest.fn(), + taxonomyOptionsFetcher: jest.fn(), + template, + }; + + const lastFormProps = (): Record => { + const { calls } = mockMetadataInstanceForm.mock; + const [firstArg] = calls[calls.length - 1]; + return firstArg; + }; + + beforeEach(() => { + mockMetadataInstanceForm.mockClear(); + }); + + test('disables the user field by default', () => { + render(); + + expect(lastFormProps()).toEqual(expect.objectContaining({ isUserFieldEnabled: false })); + }); + + test('passes isUserFieldEnabled, fetchUsers and fetchAvatarUrls through to MetadataInstanceForm', () => { + const fetchUsers = jest.fn(); + const fetchAvatarUrls = jest.fn(); + + render( + , + ); + + expect(lastFormProps()).toEqual( + expect.objectContaining({ + fetchAvatarUrls, + fetchUsers, + isUserFieldEnabled: true, + }), + ); + }); +}); diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-userField.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-userField.test.tsx new file mode 100644 index 0000000000..a1a29c0cc1 --- /dev/null +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign-userField.test.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { userEvent } from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import type { RouteComponentProps } from 'react-router-dom'; +import type { MetadataTemplate, MetadataTemplateInstance } from '@box/metadata-editor'; +import { screen, render } from '../../../test-utils/testing-library'; +import { + MetadataSidebarRedesignComponent as MetadataSidebarRedesign, + type MetadataSidebarRedesignProps, +} from '../MetadataSidebarRedesign'; +import useSidebarMetadataFetcher, { STATUS } from '../hooks/useSidebarMetadataFetcher'; +import useMetadataFieldSelection from '../hooks/useMetadataFieldSelection'; +import type { MetadataInstanceEditorProps } from '../MetadataInstanceEditor'; + +jest.mock('../hooks/useSidebarMetadataFetcher'); +jest.mock('../hooks/useMetadataFieldSelection'); + +// Stubbing the editor lets us inspect the props the sidebar hands to it +// without pulling the full @box/metadata-editor form tree into the test. +const mockEditor = jest.fn(() => null); +jest.mock('../MetadataInstanceEditor', () => ({ + __esModule: true, + default: (props: MetadataInstanceEditorProps) => mockEditor(props), +})); + +// Stubbing the package components keeps this test independent of the real +// @box/metadata-editor build (which crashes under jest when yarn-linked, +// due to a duplicate React instance). +jest.mock('@box/metadata-editor', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires -- jest.mock factories are hoisted above imports, so React must be required lazily + const ReactLib = require('react'); + + return { + AddMetadataTemplateDropdown: () => null, + AutofillContextProvider: ({ children }) => children, + FilterInstancesDropdown: () => null, + MetadataEmptyState: () => null, + MetadataInstanceList: ({ onEdit, templateInstances }) => + ReactLib.createElement( + 'div', + null, + templateInstances.map(instance => + ReactLib.createElement( + 'button', + { key: instance.id, onClick: () => onEdit(instance) }, + `Edit ${instance.displayName}`, + ), + ), + ), + }; +}); + +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 userFieldTemplateInstance = { + canEdit: true, + displayName: 'User Template', + fields: [ + { + id: 'field-1', + type: 'user', + key: 'owner', + displayName: 'Owner', + hidden: false, + }, + ], + hidden: false, + id: 'user_instance', + scope: 'global', + templateKey: 'userTemplate', + type: 'metadata_template', +} as unknown as MetadataTemplateInstance; + +const api = { options: { token: jest.fn().mockResolvedValue({ read: 'r', write: 'w' }) } }; + +const renderSidebar = ( + features: Record = {}, + overrideProps: Partial = {}, +) => { + 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, + ...overrideProps, + } satisfies MetadataSidebarRedesignProps; + + return render(, { wrapperProps: { features } }); +}; + +describe('MetadataSidebarRedesign user field 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: [userFieldTemplateInstance], + templates: mockTemplates, + errorMessage: null, + status: STATUS.SUCCESS, + file: mockFile, + extractErrorCode: null, + }); + }); + + const enterEditMode = async () => { + const editButton = await screen.findByRole('button', { name: /Edit User Template/i }); + await userEvent.click(editButton); + }; + + const lastEditorProps = (): MetadataInstanceEditorProps => { + const { calls } = mockEditor.mock; + const [firstArg] = calls[calls.length - 1]; + return firstArg; + }; + + test('passes the flag and fetchers to the editor when the user field flag is on', async () => { + renderSidebar({ 'metadata.userField.enabled': true }); + await enterEditMode(); + + const editorProps = lastEditorProps(); + expect(editorProps.isMetadataUserFieldEnabled).toBe(true); + expect(editorProps.fetchUsers).toEqual(expect.any(Function)); + expect(editorProps.fetchAvatarUrls).toEqual(expect.any(Function)); + }); + + test('keeps the user field disabled when the flag is off', async () => { + renderSidebar({ 'metadata.userField.enabled': false }); + await enterEditMode(); + + // Fetchers are always provided; @box/metadata-editor gates rendering on the flag. + const editorProps = lastEditorProps(); + expect(editorProps.isMetadataUserFieldEnabled).toBe(false); + expect(editorProps.fetchUsers).toEqual(expect.any(Function)); + expect(editorProps.fetchAvatarUrls).toEqual(expect.any(Function)); + }); + + test('prefers host-provided fetcher overrides over the default API fetchers', async () => { + const fetchUsers = jest.fn(); + const fetchAvatarUrls = jest.fn(); + + renderSidebar({ 'metadata.userField.enabled': true }, { fetchAvatarUrls, fetchUsers }); + await enterEditMode(); + + const editorProps = lastEditorProps(); + expect(editorProps.fetchUsers).toBe(fetchUsers); + expect(editorProps.fetchAvatarUrls).toBe(fetchAvatarUrls); + }); + + test('falls back to the default fetcher for any override the host does not provide', async () => { + const fetchUsers = jest.fn(); + + renderSidebar({ 'metadata.userField.enabled': true }, { fetchUsers }); + await enterEditMode(); + + const editorProps = lastEditorProps(); + expect(editorProps.fetchUsers).toBe(fetchUsers); + expect(editorProps.fetchAvatarUrls).toEqual(expect.any(Function)); + }); +}); diff --git a/src/elements/content-sidebar/__tests__/metadataUserFetcher.test.ts b/src/elements/content-sidebar/__tests__/metadataUserFetcher.test.ts new file mode 100644 index 0000000000..5dccf07c8d --- /dev/null +++ b/src/elements/content-sidebar/__tests__/metadataUserFetcher.test.ts @@ -0,0 +1,181 @@ +import type { UserContactType } from '@box/user-selector'; +import { createFetchAvatarUrls, createFetchUsers, mapToUserContact } from '../fetchers/metadataUserFetcher'; +import type API from '../../../api'; +import type { GroupMini, UserMini } from '../../../common/types/core'; + +describe('metadataUserFetcher', () => { + const fileId = '12345'; + + const getUsersInEnterpriseMock = jest.fn(); + const getGroupsInEnterpriseMock = jest.fn(); + const getAvatarUrlWithAccessTokenMock = jest.fn(); + + const apiMock = { + getMarkerBasedUsersAPI: jest.fn().mockReturnValue({ getUsersInEnterprise: getUsersInEnterpriseMock }), + getMarkerBasedGroupsAPI: jest.fn().mockReturnValue({ getGroupsInEnterprise: getGroupsInEnterpriseMock }), + getUsersAPI: jest.fn().mockReturnValue({ getAvatarUrlWithAccessToken: getAvatarUrlWithAccessTokenMock }), + } as unknown as jest.Mocked; + + const mockUser: UserMini = { + id: '111', + name: 'Albert Einstein', + email: 'albert@example.com', + type: 'user', + }; + + const mockGroup: GroupMini = { + id: '222', + name: 'Physics Department', + type: 'group', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('mapToUserContact', () => { + test('maps a user with email', () => { + expect(mapToUserContact(mockUser)).toEqual({ + email: 'albert@example.com', + id: 111, + name: 'Albert Einstein', + type: 'user', + value: '111', + }); + }); + + test('falls back to login when email is missing', () => { + const userWithLoginOnly = { ...mockUser, email: undefined, login: 'albert@box.com' }; + + expect(mapToUserContact(userWithLoginOnly).email).toBe('albert@box.com'); + }); + + test('maps a group with empty email', () => { + expect(mapToUserContact(mockGroup)).toEqual({ + email: '', + id: 222, + name: 'Physics Department', + type: 'group', + value: '222', + }); + }); + + test('collapses non-numeric id to 0 while preserving value', () => { + const userWithExternalId = { ...mockUser, id: 'external-abc' }; + + expect(mapToUserContact(userWithExternalId)).toEqual( + expect.objectContaining({ id: 0, value: 'external-abc' }), + ); + }); + }); + + describe('createFetchUsers', () => { + test('fetches enterprise users and groups with filter_term and merges them', async () => { + getUsersInEnterpriseMock.mockImplementation((id, successCallback) => + successCallback({ entries: [mockUser] }), + ); + getGroupsInEnterpriseMock.mockImplementation((id, successCallback) => + successCallback({ entries: [mockGroup] }), + ); + + const result = await createFetchUsers(apiMock, fileId)('alb'); + + expect(getUsersInEnterpriseMock).toHaveBeenCalledWith(fileId, expect.any(Function), expect.any(Function), { + filter_term: 'alb', + }); + expect(getGroupsInEnterpriseMock).toHaveBeenCalledWith(fileId, expect.any(Function), expect.any(Function), { + filter_term: 'alb', + }); + expect(result).toEqual([ + { email: 'albert@example.com', id: 111, name: 'Albert Einstein', type: 'user', value: '111' }, + { email: '', id: 222, name: 'Physics Department', type: 'group', value: '222' }, + ]); + }); + + test('returns groups when the users request fails', async () => { + getUsersInEnterpriseMock.mockImplementation((id, successCallback, errorCallback) => + errorCallback(new Error('users failed')), + ); + getGroupsInEnterpriseMock.mockImplementation((id, successCallback) => + successCallback({ entries: [mockGroup] }), + ); + + const result = await createFetchUsers(apiMock, fileId)('alb'); + + expect(result).toEqual([{ email: '', id: 222, name: 'Physics Department', type: 'group', value: '222' }]); + }); + + test('returns users when the groups request fails', async () => { + getUsersInEnterpriseMock.mockImplementation((id, successCallback) => + successCallback({ entries: [mockUser] }), + ); + getGroupsInEnterpriseMock.mockImplementation((id, successCallback, errorCallback) => + errorCallback(new Error('groups failed')), + ); + + const result = await createFetchUsers(apiMock, fileId)('alb'); + + expect(result).toEqual([ + { email: 'albert@example.com', id: 111, name: 'Albert Einstein', type: 'user', value: '111' }, + ]); + }); + + test('returns an empty list when both responses have no entries', async () => { + getUsersInEnterpriseMock.mockImplementation((id, successCallback) => successCallback({})); + getGroupsInEnterpriseMock.mockImplementation((id, successCallback) => successCallback({})); + + const result = await createFetchUsers(apiMock, fileId)('alb'); + + expect(result).toEqual([]); + }); + }); + + describe('createFetchAvatarUrls', () => { + const userContact: UserContactType = { + email: 'albert@example.com', + id: 111, + name: 'Albert Einstein', + type: 'user', + value: '111', + }; + const groupContact: UserContactType = { + email: '', + id: 222, + name: 'Physics Department', + type: 'group', + value: '222', + }; + + test('resolves avatar urls keyed by contact value', async () => { + getAvatarUrlWithAccessTokenMock.mockResolvedValue('https://example.com/avatar?access_token=token'); + + const result = await createFetchAvatarUrls(apiMock, fileId)([userContact]); + + expect(getAvatarUrlWithAccessTokenMock).toHaveBeenCalledWith('111', fileId); + expect(result).toEqual({ '111': 'https://example.com/avatar?access_token=token' }); + }); + + test('skips groups entirely', async () => { + const result = await createFetchAvatarUrls(apiMock, fileId)([groupContact]); + + expect(getAvatarUrlWithAccessTokenMock).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + + test('omits contacts without an avatar url', async () => { + getAvatarUrlWithAccessTokenMock.mockResolvedValue(null); + + const result = await createFetchAvatarUrls(apiMock, fileId)([userContact]); + + expect(result).toEqual({}); + }); + + test('omits contacts whose avatar request fails', async () => { + getAvatarUrlWithAccessTokenMock.mockRejectedValue(new Error('avatar failed')); + + const result = await createFetchAvatarUrls(apiMock, fileId)([userContact]); + + expect(result).toEqual({}); + }); + }); +}); diff --git a/src/elements/content-sidebar/fetchers/metadataUserFetcher.ts b/src/elements/content-sidebar/fetchers/metadataUserFetcher.ts new file mode 100644 index 0000000000..b3ea619a81 --- /dev/null +++ b/src/elements/content-sidebar/fetchers/metadataUserFetcher.ts @@ -0,0 +1,85 @@ +import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector'; +import type { + FetchAvatarUrls, + FetchUsers, +} from '@box/metadata-editor/lib/components/metadata-editor-fields/components/metadata-user-field/types.js'; + +import type API from '../../../api'; +import type { GroupCollection, GroupMini, UserCollection, UserMini } from '../../../common/types/core'; + +/** + * Maps an enterprise user or group to the `UserContactType` shape expected by the metadata + * user field from `@box/metadata-editor`. `value` carries the real id (it becomes the + * submitted field value), while the numeric `id` is only used by `@box/user-selector` + * internally (e.g. avatar initials color), so non-numeric ids collapse to 0. + */ +export const mapToUserContact = (item: UserMini | GroupMini): UserContactType => ({ + email: item.type === 'user' ? item.email ?? item.login ?? '' : '', + id: Number(item.id) || 0, + name: item.name, + type: item.type, + value: item.id, +}); + +/** + * Creates the `FetchUsers` prop for the metadata user field. Searches enterprise users and + * groups in parallel (same pattern as `useContacts` in content-sharing) and merges both into + * a single contact list. A failed leg resolves to an empty list so the picker degrades to + * "no results" instead of breaking when e.g. the groups endpoint errors. + */ +export const createFetchUsers = + (api: API, fileId: string): FetchUsers => + (inputValue: string): Promise => { + const usersPromise = new Promise>(resolve => { + api.getMarkerBasedUsersAPI(false).getUsersInEnterprise( + fileId, + (response: UserCollection) => resolve(response.entries ?? []), + () => resolve([]), + { filter_term: inputValue }, + ); + }); + + const groupsPromise = new Promise>(resolve => { + api.getMarkerBasedGroupsAPI(false).getGroupsInEnterprise( + fileId, + (response: GroupCollection) => resolve(response.entries ?? []), + () => resolve([]), + { filter_term: inputValue }, + ); + }); + + return Promise.all([usersPromise, groupsPromise]).then(([users, groups]) => + [...users, ...groups].map(mapToUserContact), + ); + }; + +/** + * Creates the `FetchAvatarUrls` prop for the metadata user field. Resolves avatar URLs with + * an access token attached (cached per user by the Users API) for the given contacts, keyed + * by contact id. Groups and contacts whose avatar cannot be resolved are omitted from the + * map — the selector falls back to initials for missing keys. + */ +export const createFetchAvatarUrls = + (api: API, fileId: string): FetchAvatarUrls => + async (userContacts: UserContactType[]): Promise => { + const usersApi = api.getUsersAPI(false); + const avatarUrlMap: FetchedAvatarUrls = {}; + + await Promise.all( + userContacts + .filter(contact => contact.type !== 'group') + .map(async contact => { + try { + const avatarUrl = await usersApi.getAvatarUrlWithAccessToken(contact.value, fileId); + + if (avatarUrl) { + avatarUrlMap[contact.value] = avatarUrl; + } + } catch { + // A missing avatar is not an error state - the selector renders initials instead. + } + }), + ); + + return avatarUrlMap; + }; diff --git a/src/elements/content-sidebar/stories/MetadataSidebarRedesign.stories.tsx b/src/elements/content-sidebar/stories/MetadataSidebarRedesign.stories.tsx index de7850775f..867ac7ad7e 100644 --- a/src/elements/content-sidebar/stories/MetadataSidebarRedesign.stories.tsx +++ b/src/elements/content-sidebar/stories/MetadataSidebarRedesign.stories.tsx @@ -1,6 +1,6 @@ import React, { type ComponentProps } from 'react'; import { type StoryObj, Meta } from '@storybook/react'; -import { fn, userEvent, within } from 'storybook/test'; +import { expect, fn, screen, userEvent, waitFor, within } from 'storybook/test'; import { http, HttpResponse } from 'msw'; import type { HttpHandler } from 'msw'; import MetadataSidebarRedesign from '../MetadataSidebarRedesign'; @@ -12,6 +12,7 @@ import { mockMetadataInstances, mockGlobalMetadataTemplates, } from './__mocks__/MetadataSidebarRedesignedMocks'; +import { mockMetadataTemplatesWithUserField, userFieldMockHandlers } from './__mocks__/UserFieldMocks'; const mockFeatures = { 'metadata.redesign.enabled': true, @@ -81,3 +82,46 @@ export const Basic: StoryObj = { await userEvent.click(addTemplateButton); }, }; + +const waitForLoadingToComplete = async (canvas: ReturnType) => { + await waitFor( + () => { + expect(canvas.queryByRole('status', { name: 'Loading' })).not.toBeInTheDocument(); + }, + { timeout: 10000 }, + ); +}; + +export const UserField: StoryObj = { + args: { + features: { + ...mockFeatures, + 'metadata.userField.enabled': true, + }, + }, + parameters: { + msw: { + handlers: [ + ...userFieldMockHandlers, + http.get(mockMetadataTemplatesWithUserField.url, () => { + return HttpResponse.json(mockMetadataTemplatesWithUserField.response); + }), + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitForLoadingToComplete(canvas); + + const editButton = await canvas.findByRole('button', { name: 'Edit User Template' }, { timeout: 10000 }); + await userEvent.click(editButton); + + const ownerField = await canvas.findByRole('combobox', { name: /Owner/i }); + await userEvent.click(ownerField); + await userEvent.type(ownerField, 'Bob'); + + const bobOption = await screen.findByRole('option', { name: /Bob Smith/i }); + expect(bobOption).toBeInTheDocument(); + }, +}; 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`] = `