From b0a0a80c6af4d0ee89cb27302721160ce001dbc1 Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Mon, 25 Aug 2025 16:14:32 -0400 Subject: [PATCH 1/3] feat(metadata-view): pass-thru onSortChange from the element to the shared-feature --- src/api/Metadata.js | 28 ++++++++- src/elements/content-explorer/Content.tsx | 1 + .../content-explorer/ContentExplorer.tsx | 6 +- .../MetadataViewContainer.tsx | 62 ++++++++++++++++++- .../__tests__/ContentExplorer.test.tsx | 38 +++++++++++- .../tests/MetadataView-visual.stories.tsx | 31 +++++++--- 6 files changed, 150 insertions(+), 16 deletions(-) diff --git a/src/api/Metadata.js b/src/api/Metadata.js index f26fc9c4c7..9a0ff296fb 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -90,6 +90,16 @@ class Metadata extends File { return `${this.getMetadataCacheKey(id)}_classification`; } + /** + * Creates a key for the metadata template schema cache + * + * @param {string} templateKey - template key + * @return {string} key + */ + getMetadataTemplateSchemaCacheKey(templateKey: string): string { + return `${CACHE_PREFIX_METADATA}template_schema_${templateKey}`; + } + /** * API URL for metadata * @@ -337,9 +347,23 @@ class Metadata extends File { * @param {string} templateKey - template key * @return {Promise} Promise object of metadata template */ - getSchemaByTemplateKey(templateKey: string): Promise { + async getSchemaByTemplateKey(templateKey: string): Promise { + const cache: APICache = this.getCache(); + const key = this.getMetadataTemplateSchemaCacheKey(templateKey); + + // Return cached value if it exists + if (cache.has(key)) { + return cache.get(key); + } + + // Fetch from API if not cached const url = this.getMetadataTemplateSchemaUrl(templateKey); - return this.xhr.get({ url }); + const response = await this.xhr.get({ url }); + + // Cache the response + cache.set(key, response); + + return response; } /** diff --git a/src/elements/content-explorer/Content.tsx b/src/elements/content-explorer/Content.tsx index 5bb42fe8ef..2d6ebd8b0f 100644 --- a/src/elements/content-explorer/Content.tsx +++ b/src/elements/content-explorer/Content.tsx @@ -89,6 +89,7 @@ const Content = ({ isLoading={percentLoaded !== 100} hasError={view === VIEW_ERROR} metadataTemplate={metadataTemplate} + onSortChange={onSortChange} {...metadataViewProps} /> )} diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index b9cac8d468..f147235727 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -10,7 +10,7 @@ import throttle from 'lodash/throttle'; import uniqueid from 'lodash/uniqueId'; import { TooltipProvider } from '@box/blueprint-web'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; -import type { Selection } from 'react-aria-components'; +import type { Key, Selection } from 'react-aria-components'; import CreateFolderDialog from '../common/create-folder-dialog'; import UploadDialog from '../common/upload-dialog'; @@ -152,7 +152,7 @@ export interface ContentExplorerProps { rootFolderId?: string; sharedLink?: string; sharedLinkPassword?: string; - sortBy?: SortBy; + sortBy?: SortBy | Key; sortDirection?: SortDirection; staticHost?: string; staticPath?: string; @@ -895,7 +895,7 @@ class ContentExplorer extends Component { * @param {string} sortDirection - sort direction * @return {void} */ - sort = (sortBy: SortBy, sortDirection: SortDirection) => { + sort = (sortBy: SortBy | Key, sortDirection: SortDirection) => { const { currentCollection: { id }, view, diff --git a/src/elements/content-explorer/MetadataViewContainer.tsx b/src/elements/content-explorer/MetadataViewContainer.tsx index 8149835b49..8c769aadd9 100644 --- a/src/elements/content-explorer/MetadataViewContainer.tsx +++ b/src/elements/content-explorer/MetadataViewContainer.tsx @@ -1,7 +1,9 @@ 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 Key } from '@react-types/shared'; +import { SortDescriptor } from 'react-aria-components'; import type { Collection } from '../../common/types/core'; import type { MetadataTemplate } from '../../common/types/metadata'; @@ -25,6 +27,21 @@ type ActionBarProps = Omit< onFilterSubmit?: (filterValues: ExternalFilterValues) => void; }; +/** + * Helper function to trim metadataFieldNamePrefix from column names + * For example: 'metadata.enterprise_1515946.mdViewTemplate1.industry' -> 'industry' + */ +function trimMetadataFieldPrefix(column: string): string { + // Check if the column starts with 'metadata.' and contains at least 2 dots + if (column.startsWith('metadata.') && column.split('.').length >= 3) { + // Split by dots and take everything after the first 3 parts + // metadata.enterprise_1515946.mdViewTemplate1.industry -> industry + const parts = column.split('.'); + return parts.slice(3).join('.'); + } + return column; +} + function transformInitialFilterValuesToInternal( publicValues?: ExternalFilterValues, ): Record | undefined { @@ -55,6 +72,8 @@ export interface MetadataViewContainerProps extends Omit void; } const MetadataViewContainer = ({ @@ -62,6 +81,7 @@ const MetadataViewContainer = ({ columns, currentCollection, metadataTemplate, + onSortChange: onSortChangeBUIE, ...rest }: MetadataViewContainerProps) => { const { items = [] } = currentCollection; @@ -111,7 +131,47 @@ const MetadataViewContainer = ({ }; }, [actionBarProps, initialFilterValues, onFilterSubmit, filterGroups]); - return ; + // Extract the original tableProps.onSortChange from rest + const { tableProps, ...otherRest } = rest; + const onSortChangeConsumer = tableProps?.onSortChange; + + // Create a wrapper function that calls both. The wrapper function should follow the signature of onSortChange from RAC + const handleSortChange = React.useCallback( + ({ column, direction }: SortDescriptor) => { + // Call the internal onSortChange first + // API accepts asc/desc "https://developer.box.com/reference/post-metadata-queries-execute-read/" + if (onSortChangeBUIE) { + const trimmedColumn = trimMetadataFieldPrefix(String(column)); + onSortChangeBUIE(trimmedColumn, direction === 'ascending' ? 'ASC' : 'DESC'); + } + + // Then call the original customer-provided onSortChange if it exists + // Accepts "ascending" / "descending" (https://react-spectrum.adobe.com/react-aria/Table.html) + if (onSortChangeConsumer) { + onSortChangeConsumer({ + column, + direction, + }); + } + }, + [onSortChangeBUIE, onSortChangeConsumer], + ); + + // Create new tableProps with our wrapper function + const newTableProps = { + ...tableProps, + onSortChange: handleSortChange, + }; + + return ( + + ); }; export default MetadataViewContainer; diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index bea8fb77ba..2d8162349f 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -454,7 +454,7 @@ describe('elements/content-explorer/ContentExplorer', () => { textValue: 'Name', id: 'name', type: 'string' as const, - allowSorting: true, + allowsSorting: true, minWidth: 150, maxWidth: 150, }, @@ -462,7 +462,7 @@ describe('elements/content-explorer/ContentExplorer', () => { textValue: field.displayName, id: `${metadataFieldNamePrefix}.${field.key}`, type: field.type as MetadataFieldType, - allowSorting: true, + allowsSorting: true, minWidth: 150, maxWidth: 150, })), @@ -506,6 +506,40 @@ describe('elements/content-explorer/ContentExplorer', () => { expect(screen.getByRole('button', { name: 'Metadata' })).toBeInTheDocument(); }); + test('should call both internal and user onSortChange callbacks when sorting by a metadata field', async () => { + const mockOnSortChangeBUIE = jest.fn(); + const mockOnSortChangeConsumer = jest.fn(); + + renderComponent({ + ...metadataViewV2ElementProps, + metadataViewProps: { + ...metadataViewV2ElementProps.metadataViewProps, + onSortChange: mockOnSortChangeBUIE, // Internal callback - receives trimmed column name + tableProps: { + ...metadataViewV2ElementProps.metadataViewProps.tableProps, + onSortChange: mockOnSortChangeConsumer, // User callback - receives full column ID + }, + }, + }); + + const industryHeader = await screen.findByRole('columnheader', { name: 'Industry' }); + expect(industryHeader).toBeInTheDocument(); + + const firstRow = await screen.findByRole('row', { name: /Child 2/i }); + expect(firstRow).toBeInTheDocument(); + + await userEvent.click(industryHeader); + + // Internal callback gets trimmed version for API calls + expect(mockOnSortChangeBUIE).toHaveBeenCalledWith('industry', 'ASC'); + + // User callback gets full column ID with direction + expect(mockOnSortChangeConsumer).toHaveBeenCalledWith({ + column: 'metadata.enterprise_0.templateName.industry', + direction: 'ascending', + }); + }); + test('should call onClick when bulk item action is clicked', async () => { let mockOnClickArg; const mockOnClick = jest.fn(arg => { 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 e2aab4c225..a1a75cadc8 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -3,7 +3,9 @@ import { http, HttpResponse } from 'msw'; import { Download, SignMeOthers } from '@box/blueprint-web-assets/icons/Fill/index'; import { Sign } from '@box/blueprint-web-assets/icons/Line'; import { expect, fn, userEvent, waitFor, within, screen } from 'storybook/test'; + import noop from 'lodash/noop'; +import orderBy from 'lodash/orderBy'; import ContentExplorer from '../../ContentExplorer'; import { DEFAULT_HOSTNAME_API } from '../../../../constants'; @@ -138,17 +140,16 @@ export const metadataViewV2: Story = { args: metadataViewV2ElementProps, }; -// @TODO Assert that rows are actually sorted in a different order, once handleSortChange is implemented export const metadataViewV2SortsFromHeader: Story = { args: metadataViewV2ElementProps, play: async ({ canvas }) => { - await waitFor(() => { - expect(canvas.getByRole('row', { name: /Industry/i })).toBeInTheDocument(); - }); + const industryHeader = await canvas.findByRole('columnheader', { name: 'Industry' }); + expect(industryHeader).toBeInTheDocument(); - const firstRow = canvas.getByRole('row', { name: /Industry/i }); - const industryHeader = within(firstRow).getByRole('columnheader', { name: 'Industry' }); - userEvent.click(industryHeader); + const firstRow = await canvas.findByRole('row', { name: /Child 2/i }); + expect(firstRow).toBeInTheDocument(); + + await userEvent.click(industryHeader); }, }; @@ -248,7 +249,21 @@ const meta: Meta = { parameters: { msw: { handlers: [ - http.post(`${DEFAULT_HOSTNAME_API}/2.0/metadata_queries/execute_read`, () => { + // Note that the Metadata API backend normally handles the sorting. The mocks below simulate the sorting for specific cases, but may not 100% accurately reflect the backend behavior. + http.post(`${DEFAULT_HOSTNAME_API}/2.0/metadata_queries/execute_read`, async ({ request }) => { + const body = await request.clone().json(); + const orderByDirection = body.order_by[0].direction; + const orderByFieldKey = body.order_by[0].field_key; + + // Hardcoded case for sorting by industry + if (orderByFieldKey === `${metadataFieldNamePrefix}.industry` && orderByDirection === 'ASC') { + const sortedMetadata = orderBy( + mockMetadata.entries, + 'metadata.enterprise_0.templateName.industry', + 'asc', + ); + return HttpResponse.json({ ...mockMetadata, entries: sortedMetadata }); + } return HttpResponse.json(mockMetadata); }), http.get(`${DEFAULT_HOSTNAME_API}/2.0/metadata_templates/enterprise/templateName/schema`, () => { From 66128b4c510da1f122b514673696168902562021 Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Wed, 27 Aug 2025 16:38:57 -0400 Subject: [PATCH 2/3] feat(metadata-view): pass-thru onSortChange --- .../content-explorer/MetadataViewContainer.tsx | 14 +++++++------- .../stories/tests/MetadataView-visual.stories.tsx | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/elements/content-explorer/MetadataViewContainer.tsx b/src/elements/content-explorer/MetadataViewContainer.tsx index 8c769aadd9..73eafc9ba7 100644 --- a/src/elements/content-explorer/MetadataViewContainer.tsx +++ b/src/elements/content-explorer/MetadataViewContainer.tsx @@ -81,7 +81,7 @@ const MetadataViewContainer = ({ columns, currentCollection, metadataTemplate, - onSortChange: onSortChangeBUIE, + onSortChange: onSortChangeInternal, ...rest }: MetadataViewContainerProps) => { const { items = [] } = currentCollection; @@ -133,28 +133,28 @@ const MetadataViewContainer = ({ // Extract the original tableProps.onSortChange from rest const { tableProps, ...otherRest } = rest; - const onSortChangeConsumer = tableProps?.onSortChange; + const onSortChangeExternal = tableProps?.onSortChange; // Create a wrapper function that calls both. The wrapper function should follow the signature of onSortChange from RAC const handleSortChange = React.useCallback( ({ column, direction }: SortDescriptor) => { // Call the internal onSortChange first // API accepts asc/desc "https://developer.box.com/reference/post-metadata-queries-execute-read/" - if (onSortChangeBUIE) { + if (onSortChangeInternal) { const trimmedColumn = trimMetadataFieldPrefix(String(column)); - onSortChangeBUIE(trimmedColumn, direction === 'ascending' ? 'ASC' : 'DESC'); + onSortChangeInternal(trimmedColumn, direction === 'ascending' ? 'ASC' : 'DESC'); } // Then call the original customer-provided onSortChange if it exists // Accepts "ascending" / "descending" (https://react-spectrum.adobe.com/react-aria/Table.html) - if (onSortChangeConsumer) { - onSortChangeConsumer({ + if (onSortChangeExternal) { + onSortChangeExternal({ column, direction, }); } }, - [onSortChangeBUIE, onSortChangeConsumer], + [onSortChangeInternal, onSortChangeExternal], ); // Create new tableProps with our wrapper function 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 a1a75cadc8..f3d56bd0b3 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -256,7 +256,7 @@ const meta: Meta = { const orderByFieldKey = body.order_by[0].field_key; // Hardcoded case for sorting by industry - if (orderByFieldKey === `${metadataFieldNamePrefix}.industry` && orderByDirection === 'ASC') { + if (orderByFieldKey === `industry` && orderByDirection === 'ASC') { const sortedMetadata = orderBy( mockMetadata.entries, 'metadata.enterprise_0.templateName.industry', From 573134fb4f1717b0869cddf49503a281362b45ca Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Wed, 27 Aug 2025 19:21:31 -0400 Subject: [PATCH 3/3] feat(metadata-view): pass-thru onSortChange --- .../__tests__/ContentExplorer.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index 2d8162349f..fbc2ae2479 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -507,17 +507,17 @@ describe('elements/content-explorer/ContentExplorer', () => { }); test('should call both internal and user onSortChange callbacks when sorting by a metadata field', async () => { - const mockOnSortChangeBUIE = jest.fn(); - const mockOnSortChangeConsumer = jest.fn(); + const mockOnSortChangeInternal = jest.fn(); + const mockOnSortChangeExternal = jest.fn(); renderComponent({ ...metadataViewV2ElementProps, metadataViewProps: { ...metadataViewV2ElementProps.metadataViewProps, - onSortChange: mockOnSortChangeBUIE, // Internal callback - receives trimmed column name + onSortChange: mockOnSortChangeInternal, // Internal callback - receives trimmed column name tableProps: { ...metadataViewV2ElementProps.metadataViewProps.tableProps, - onSortChange: mockOnSortChangeConsumer, // User callback - receives full column ID + onSortChange: mockOnSortChangeExternal, // User callback - receives full column ID }, }, }); @@ -531,10 +531,10 @@ describe('elements/content-explorer/ContentExplorer', () => { await userEvent.click(industryHeader); // Internal callback gets trimmed version for API calls - expect(mockOnSortChangeBUIE).toHaveBeenCalledWith('industry', 'ASC'); + expect(mockOnSortChangeInternal).toHaveBeenCalledWith('industry', 'ASC'); // User callback gets full column ID with direction - expect(mockOnSortChangeConsumer).toHaveBeenCalledWith({ + expect(mockOnSortChangeExternal).toHaveBeenCalledWith({ column: 'metadata.enterprise_0.templateName.industry', direction: 'ascending', });