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 d8bebcfae8..4011268574 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'; @@ -153,7 +153,7 @@ export interface ContentExplorerProps { rootFolderId?: string; sharedLink?: string; sharedLinkPassword?: string; - sortBy?: SortBy; + sortBy?: SortBy | Key; sortDirection?: SortDirection; staticHost?: string; staticPath?: string; @@ -896,7 +896,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..73eafc9ba7 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: onSortChangeInternal, ...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 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 (onSortChangeInternal) { + const trimmedColumn = trimMetadataFieldPrefix(String(column)); + 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 (onSortChangeExternal) { + onSortChangeExternal({ + column, + direction, + }); + } + }, + [onSortChangeInternal, onSortChangeExternal], + ); + + // 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..fbc2ae2479 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 mockOnSortChangeInternal = jest.fn(); + const mockOnSortChangeExternal = jest.fn(); + + renderComponent({ + ...metadataViewV2ElementProps, + metadataViewProps: { + ...metadataViewV2ElementProps.metadataViewProps, + onSortChange: mockOnSortChangeInternal, // Internal callback - receives trimmed column name + tableProps: { + ...metadataViewV2ElementProps.metadataViewProps.tableProps, + onSortChange: mockOnSortChangeExternal, // 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(mockOnSortChangeInternal).toHaveBeenCalledWith('industry', 'ASC'); + + // User callback gets full column ID with direction + expect(mockOnSortChangeExternal).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..f3d56bd0b3 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 === `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`, () => {