diff --git a/package.json b/package.json index cf20a83d40..4220a269dc 100644 --- a/package.json +++ b/package.json @@ -128,15 +128,15 @@ "@box/blueprint-web-assets": "4.61.5", "@box/box-ai-agent-selector": "^0.52.0", "@box/box-ai-content-answers": "^0.124.1", - "@box/box-item-type-selector": "^0.61.12", + "@box/box-item-type-selector": "^0.63.12", "@box/cldr-data": "^34.2.0", "@box/combobox-with-api": "^0.34.9", "@box/frontend": "^11.0.1", - "@box/item-icon": "^0.17.0", + "@box/item-icon": "^0.17.15", "@box/languages": "^1.0.0", "@box/metadata-editor": "^0.122.12", - "@box/metadata-filter": "^1.16.12", - "@box/metadata-view": "^0.29.4", + "@box/metadata-filter": "^1.19.2", + "@box/metadata-view": "^0.41.2", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@cfaester/enzyme-adapter-react-18": "^0.8.0", @@ -297,13 +297,13 @@ "@box/blueprint-web-assets": "4.61.5", "@box/box-ai-agent-selector": "^0.52.0", "@box/box-ai-content-answers": "^0.124.1", - "@box/box-item-type-selector": "^0.61.12", + "@box/box-item-type-selector": "^0.63.12", "@box/cldr-data": ">=34.2.0", "@box/combobox-with-api": "^0.34.9", - "@box/item-icon": "^0.17.0", + "@box/item-icon": "^0.17.15", "@box/metadata-editor": "^0.122.12", - "@box/metadata-filter": "^1.16.12", - "@box/metadata-view": "^0.29.4", + "@box/metadata-filter": "^1.19.2", + "@box/metadata-view": "^0.41.2", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@hapi/address": "^2.1.4", diff --git a/src/elements/content-explorer/MetadataViewContainer.tsx b/src/elements/content-explorer/MetadataViewContainer.tsx index f142563f54..8149835b49 100644 --- a/src/elements/content-explorer/MetadataViewContainer.tsx +++ b/src/elements/content-explorer/MetadataViewContainer.tsx @@ -1,9 +1,58 @@ import * as React from 'react'; +import type { EnumType, FloatType, MetadataFormFieldValue, RangeType } from '@box/metadata-filter'; import { MetadataView, type MetadataViewProps } from '@box/metadata-view'; -import type { MetadataTemplate } from '../../common/types/metadata'; + import type { Collection } from '../../common/types/core'; +import type { MetadataTemplate } from '../../common/types/metadata'; + +// Public-friendly version of MetadataFormFieldValue from @box/metadata-filter +// (string[] for enum type, range/float objects stay the same) +type EnumToStringArray = T extends EnumType ? string[] : T; +type ExternalMetadataFormFieldValue = EnumToStringArray; + +type ExternalFilterValues = Record< + string, + { + value: ExternalMetadataFormFieldValue; + } +>; -export interface MetadataViewContainerProps extends Omit { +type ActionBarProps = Omit< + MetadataViewProps['actionBarProps'], + 'initialFilterValues' | 'onFilterSubmit' | 'filterGroups' +> & { + initialFilterValues?: ExternalFilterValues; + onFilterSubmit?: (filterValues: ExternalFilterValues) => void; +}; + +function transformInitialFilterValuesToInternal( + publicValues?: ExternalFilterValues, +): Record | undefined { + if (!publicValues) return undefined; + + return Object.entries(publicValues).reduce>( + (acc, [key, { value }]) => { + acc[key] = Array.isArray(value) ? { value: { enum: value } } : { value }; + return acc; + }, + {}, + ); +} + +function transformInternalFieldsToPublic( + fields: Record, +): ExternalFilterValues { + return Object.entries(fields).reduce((acc, [key, { value }]) => { + acc[key] = + 'enum' in value && Array.isArray(value.enum) + ? { value: value.enum } + : { value: value as RangeType | FloatType }; + return acc; + }, {}); +} + +export interface MetadataViewContainerProps extends Omit { + actionBarProps?: ActionBarProps; currentCollection: Collection; metadataTemplate: MetadataTemplate; } @@ -16,6 +65,7 @@ const MetadataViewContainer = ({ ...rest }: MetadataViewContainerProps) => { const { items = [] } = currentCollection; + const { initialFilterValues: initialFilterValuesProp, onFilterSubmit: onFilterSubmitProp } = actionBarProps ?? {}; const filterGroups = React.useMemo( () => [ @@ -36,17 +86,32 @@ const MetadataViewContainer = ({ [metadataTemplate], ); - return ( - + // Transform initial filter values to internal field format + const initialFilterValues = React.useMemo( + () => transformInitialFilterValuesToInternal(initialFilterValuesProp), + [initialFilterValuesProp], ); + + // Transform field values to public-friendly format + const onFilterSubmit = React.useCallback( + (fields: Record) => { + if (!onFilterSubmitProp) return; + const transformed = transformInternalFieldsToPublic(fields); + onFilterSubmitProp(transformed); + }, + [onFilterSubmitProp], + ); + + const transformedActionBarProps = React.useMemo(() => { + return { + ...actionBarProps, + initialFilterValues, + onFilterSubmit, + filterGroups, + }; + }, [actionBarProps, initialFilterValues, onFilterSubmit, filterGroups]); + + return ; }; export default MetadataViewContainer; diff --git a/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx index 16df4b3a0e..0fcaceb8e6 100644 --- a/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx +++ b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import { render, screen } from '../../../test-utils/testing-library'; -import MetadataViewContainer, { MetadataViewContainerProps } from '../MetadataViewContainer'; + import type { Collection } from '../../../common/types/core'; import type { MetadataTemplate, MetadataTemplateField } from '../../../common/types/metadata'; +import { render, screen, userEvent, waitFor, within } from '../../../test-utils/testing-library'; +import MetadataViewContainer, { type MetadataViewContainerProps } from '../MetadataViewContainer'; describe('elements/content-explorer/MetadataViewContainer', () => { const mockItems = [ @@ -18,7 +19,7 @@ describe('elements/content-explorer/MetadataViewContainer', () => { type: 'string', }, { - id: 'field1', + id: 'field2', key: 'industry', displayName: 'Industry', type: 'enum', @@ -80,4 +81,38 @@ describe('elements/content-explorer/MetadataViewContainer', () => { expect(screen.getByText('File 1.txt')).toBeInTheDocument(); expect(screen.getByText('File 2.pdf')).toBeInTheDocument(); }); + + test('should pass values as string[] on submit', async () => { + const onFilterSubmit = jest.fn(); + const template: MetadataTemplate = { + ...mockMetadataTemplate, + fields: [ + { + id: 'ms1', + key: 'role', + displayName: 'Contact Role', + type: 'multiSelect', + options: [ + { id: 'r1', key: 'Developer' }, + { id: 'r2', key: 'Marketing' }, + { id: 'r3', key: 'Sales' }, + ], + }, + ], + }; + + renderComponent({ metadataTemplate: template, actionBarProps: { onFilterSubmit } }); + + await userEvent().click(screen.getByRole('button', { name: /Contact Role/ })); + await userEvent().click(within(screen.getByRole('menu')).getByRole('menuitemcheckbox', { name: 'Developer' })); + // Re-open the chip to select a second value (menu closes after submit) + await userEvent().click(screen.getByRole('button', { name: /Contact Role/ })); + await userEvent().click(within(screen.getByRole('menu')).getByRole('menuitemcheckbox', { name: 'Marketing' })); + + await waitFor(() => expect(onFilterSubmit).toHaveBeenCalledTimes(2)); + const firstCall = onFilterSubmit.mock.calls[0][0]; + const secondCall = onFilterSubmit.mock.calls[1][0]; + expect(firstCall['role-filter'].value).toEqual(['Developer']); + expect(secondCall['role-filter'].value).toEqual(['Developer', 'Marketing']); + }); }); diff --git a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx index 4b92390592..93cecdca8a 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -1,9 +1,10 @@ -import { http, HttpResponse } from 'msw'; import type { Meta, StoryObj } from '@storybook/react'; +import { http, HttpResponse } from 'msw'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import { Download, SignMeOthers } from '@box/blueprint-web-assets/icons/Fill/index'; import { Sign } from '@box/blueprint-web-assets/icons/Line'; -import { expect, userEvent, waitFor, within } from 'storybook/test'; import noop from 'lodash/noop'; + import ContentExplorer from '../../ContentExplorer'; import { DEFAULT_HOSTNAME_API } from '../../../../constants'; import { mockMetadata, mockSchema } from '../../../common/__mocks__/mockMetadata'; @@ -126,13 +127,42 @@ export const metadataViewV2WithCustomActions: Story = { await waitFor(() => { expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); }); - const firstRow = canvas.getByRole('row', { name: /Child 2/i }); const ellipsesButton = within(firstRow).getByRole('button', { name: 'Action menu' }); userEvent.click(ellipsesButton); }, }; +const initialFilterActionBarProps = { + initialFilterValues: { + 'industry-filter': { value: ['Legal'] }, + 'mimetype-filter': { value: ['boxnoteType', 'documentType', 'threedType'] }, + 'role-filter': { value: ['Developer', 'Business Owner', 'Marketing'] }, + }, +}; + +export const metadataViewV2WithInitialFilterValues: Story = { + args: { + ...metadataViewV2ElementProps, + metadataViewProps: { + columns, + actionBarProps: initialFilterActionBarProps, + }, + }, + play: async ({ canvas }) => { + // Wait for chips to update with initial values + await waitFor(() => { + expect(canvas.getByRole('button', { name: /Industry/i })).toHaveTextContent(/\(1\)/); + }); + // Other chips should reflect initialized values + const contactRoleChip = canvas.getByRole('button', { name: /Contact Role/i }); + expect(contactRoleChip).toHaveTextContent(/\(3\)/); + + const fileTypeChip = canvas.getByRole('button', { name: /Box Note/i }); + expect(fileTypeChip).toHaveTextContent(/\+2/); + }, +}; + const meta: Meta = { title: 'Elements/ContentExplorer/tests/MetadataView/visual', component: ContentExplorer, diff --git a/yarn.lock b/yarn.lock index e00b79be9e..6c340595b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1475,10 +1475,10 @@ resolved "https://registry.yarnpkg.com/@box/box-ai-content-answers/-/box-ai-content-answers-0.124.5.tgz#664a34d8338569be6801c1433295f858d8f054ad" integrity sha512-0p1j2JW9ig0rhM4tiOyEFN6dbiHa7wuX8PmKTjPbgU5MQDibGSbNeZS7IxZlJLYs2BmJ+IncUrPN+zlnsfaRgw== -"@box/box-item-type-selector@^0.61.12": - version "0.61.16" - resolved "https://registry.yarnpkg.com/@box/box-item-type-selector/-/box-item-type-selector-0.61.16.tgz#476ba5d58e177f9967a064077273d0d690b887f0" - integrity sha512-0wiiqX4/x6K7lX7QfbQYxW3ELTV+9rYdRxdV2IZqE+3fzrGiSgucYtRLkyfeqsBRUFDQSfYRXHA+sE4ZdTb/fA== +"@box/box-item-type-selector@^0.63.12": + version "0.63.13" + resolved "https://registry.yarnpkg.com/@box/box-item-type-selector/-/box-item-type-selector-0.63.13.tgz#08999f0149f9222f8d7f00db20f7951c59b6da97" + integrity sha512-c3N2zTPfZqYhLgQOHEo5kE/TTrXi9BDEaPQrqb2ctsySdRCzkwLk6DfVhGFyXLQeIl0zZLhMXomSMKSxryG1cA== "@box/cldr-data@^34.2.0": version "34.8.0" @@ -1502,10 +1502,10 @@ rimraf "^3.0.0" semver "^7.4.0" -"@box/item-icon@^0.17.0": - version "0.17.0" - resolved "https://registry.yarnpkg.com/@box/item-icon/-/item-icon-0.17.0.tgz#2e92c62046ce0d35c6d510ae1f2d2a36b31d72ad" - integrity sha512-Pj4XUYMnMfjZ72ZPMxmbt//juidqArdbfyxxbXEI+yKH8hsO4jr6Z2RGAUmSepweWkxYHLm5gDzTEJwBC0csHQ== +"@box/item-icon@^0.17.15": + version "0.17.16" + resolved "https://registry.yarnpkg.com/@box/item-icon/-/item-icon-0.17.16.tgz#bd883e33a5f75e8153a9236edbf30674a1dc69c2" + integrity sha512-8uNEvBuXHdoRonmQUe5lCyyIkzFIY7Fhcb2WD9yhfgRU4wHS/rqWZ6JHicGUkpT7GPNssEwuWU44Fytqp8lHvw== "@box/languages@^1.0.0": version "1.1.2" @@ -1517,15 +1517,15 @@ resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-0.122.16.tgz#d120602dcc39a6b4cd6f441ddd5fdc4b2f2862a4" integrity sha512-fy43Z9fr6+t0hO+tlVDX4A890K0mos78GqHeZp9fGDH+lQsjHBHz3DDvcbyfmkCrf3Dbg15wrvBO0Pa4FaCSvw== -"@box/metadata-filter@^1.16.12": - version "1.18.0" - resolved "https://registry.yarnpkg.com/@box/metadata-filter/-/metadata-filter-1.18.0.tgz#c6e2a69f1ce9919063243fb451a97b4fc78ed019" - integrity sha512-us2njX6ade6iYeJekaerUv67d+yAob+o14ouYkfHzRFjLyfxDxB6ycTy/0jHwXLQYREXSZfIu6L+INEgbT2VAA== +"@box/metadata-filter@^1.19.2": + version "1.19.3" + resolved "https://registry.yarnpkg.com/@box/metadata-filter/-/metadata-filter-1.19.3.tgz#87364bea4cbb1417866e65639f3b1e137a6d9b6a" + integrity sha512-5cSY8yLW7S1zsiqBHAuKkHjcyHFBuBUBHGTnYigV0eKyLH4Dm9ozjon23P3Z9HXVB5IMHwTM3I9TRDFAZuP7vw== -"@box/metadata-view@^0.29.4": - version "0.29.4" - resolved "https://registry.yarnpkg.com/@box/metadata-view/-/metadata-view-0.29.4.tgz#03c42c0e32e9fc9895a5b56bc4a97daafeec8ca9" - integrity sha512-0CPQ7PE6uiW4hO3EOfpyLu1nD0u4UXwCMYsdHjQTHSeqgpmT8sx7ZlZ/7or+bwlekMqijdJ2CqbJhz3WsIlIfQ== +"@box/metadata-view@^0.41.2": + version "0.41.3" + resolved "https://registry.yarnpkg.com/@box/metadata-view/-/metadata-view-0.41.3.tgz#95a4d8322d02c13172fb0be681e74e17f8fe90dc" + integrity sha512-7ZqUrx4YmfmwXeDoPhSpLnL8xxVBkZ3Hlw4gpfpCw8IPHdT/nYTFml1GW7DO5d43jICf3foD08wwksW9IeB7/A== "@box/react-virtualized@^9.22.3-rc-box.10": version "9.22.3-rc-box.10"