From 1947059978336110171f906cad63bcaeb0a857ad Mon Sep 17 00:00:00 2001 From: Greg Wong Date: Wed, 13 Aug 2025 16:54:24 -0400 Subject: [PATCH] feat(metadata-view): Add Filtering --- package.json | 4 +- src/elements/common/__mocks__/mockMetadata.ts | 18 +- src/elements/content-explorer/Content.tsx | 10 +- .../content-explorer/ContentExplorer.tsx | 401 ++++++++-------- .../MetadataQueryAPIHelper.ts | 116 ++++- .../content-explorer/MetadataQueryBuilder.ts | 159 +++++++ .../MetadataViewContainer.tsx | 149 ++++-- .../__tests__/Content.test.tsx | 1 + .../__tests__/ContentExplorer.test.tsx | 7 +- .../__tests__/MetadataQueryAPIHelper.test.ts | 429 +++++++++++++++++- .../__tests__/MetadataQueryBuilder.test.ts | 419 +++++++++++++++++ .../__tests__/MetadataViewContainer.test.tsx | 422 ++++++++++++++++- .../stories/MetadataView.stories.tsx | 24 +- .../tests/MetadataView-visual.stories.tsx | 15 +- yarn.lock | 39 +- 15 files changed, 1875 insertions(+), 338 deletions(-) create mode 100644 src/elements/content-explorer/MetadataQueryBuilder.ts create mode 100644 src/elements/content-explorer/__tests__/MetadataQueryBuilder.test.ts diff --git a/package.json b/package.json index ee8f6c338e..34b43f3898 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "@box/languages": "^1.0.0", "@box/metadata-editor": "^0.122.12", "@box/metadata-filter": "^1.19.2", - "@box/metadata-view": "^0.41.2", + "@box/metadata-view": "^0.48.1", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@cfaester/enzyme-adapter-react-18": "^0.8.0", @@ -303,7 +303,7 @@ "@box/item-icon": "^0.17.15", "@box/metadata-editor": "^0.122.12", "@box/metadata-filter": "^1.19.2", - "@box/metadata-view": "^0.41.2", + "@box/metadata-view": "^0.48.1", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@hapi/address": "^2.1.4", diff --git a/src/elements/common/__mocks__/mockMetadata.ts b/src/elements/common/__mocks__/mockMetadata.ts index 8391b488d4..e6b23dfbd4 100644 --- a/src/elements/common/__mocks__/mockMetadata.ts +++ b/src/elements/common/__mocks__/mockMetadata.ts @@ -9,7 +9,6 @@ const mockMetadata = { role: ['Business Owner', 'Marketing'], $template: 'templateName', $parent: 'file_1188899160835', - name: 'something', industry: 'Technology', last_contacted_at: '2023-11-16T00:00:00.000Z', $version: 9, @@ -31,7 +30,6 @@ const mockMetadata = { role: ['Developer'], $template: 'templateName', $parent: 'file_1318276254035', - name: '1', industry: 'Technology', last_contacted_at: '2023-11-01T00:00:00.000Z', $version: 3, @@ -94,7 +92,6 @@ const mockMetadata = { $scope: 'enterprise_0', $template: 'templateName', $parent: 'file_1812508470016', - name: 'in folder 3 that doesnt have metadata', $version: 0, }, }, @@ -154,14 +151,6 @@ const mockSchema = { hidden: false, copyInstanceOnItemCopy: false, fields: [ - { - id: '56b6f00e-5db3-4875-a31d-14b20f63c0ea', - type: 'string', - key: 'name', - displayName: 'Name', - hidden: false, - description: 'The customer name', - }, { id: '07d3c06c-5db4-4f3f-821e-19219ba70ed3', type: 'date', @@ -220,6 +209,13 @@ const mockSchema = { }, ], }, + { + id: 'c3f87bb0-44df-4689-aafe-b9ed4aecbb01', + type: 'float', + key: 'number', + displayName: 'Merit Count', + hidden: false, + }, ], }; diff --git a/src/elements/content-explorer/Content.tsx b/src/elements/content-explorer/Content.tsx index 2d6ebd8b0f..d1ed0da368 100644 --- a/src/elements/content-explorer/Content.tsx +++ b/src/elements/content-explorer/Content.tsx @@ -4,7 +4,7 @@ import ItemGrid from '../common/item-grid'; import ItemList from '../common/item-list'; import ProgressBar from '../common/progress-bar'; import MetadataBasedItemList from '../../features/metadata-based-view'; -import MetadataViewContainer, { MetadataViewContainerProps } from './MetadataViewContainer'; +import MetadataViewContainer, { ExternalFilterValues, MetadataViewContainerProps } from './MetadataViewContainer'; import { isFeatureEnabled, type FeatureConfig } from '../common/feature-checking'; import { VIEW_ERROR, VIEW_METADATA, VIEW_MODE_LIST, VIEW_MODE_GRID, VIEW_SELECTED } from '../../constants'; import type { ViewMode } from '../common/flowTypes'; @@ -37,7 +37,11 @@ export interface ContentProps extends Required, Required; + metadataViewProps?: Omit< + MetadataViewContainerProps, + 'hasError' | 'currentCollection' | 'metadataTemplate' | 'onMetadataFilter' + >; + onMetadataFilter?: (fields: ExternalFilterValues) => void; onMetadataUpdate: ( item: BoxItem, field: string, @@ -57,6 +61,7 @@ const Content = ({ gridColumnCount, metadataTemplate, metadataViewProps, + onMetadataFilter, onMetadataUpdate, onSortChange, view, @@ -89,6 +94,7 @@ const Content = ({ isLoading={percentLoaded !== 100} hasError={view === VIEW_ERROR} metadataTemplate={metadataTemplate} + onMetadataFilter={onMetadataFilter} onSortChange={onSortChange} {...metadataViewProps} /> diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index b0f286e6a3..6b6b6941c7 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -8,7 +8,6 @@ import getProp from 'lodash/get'; import noop from 'lodash/noop'; import throttle from 'lodash/throttle'; import uniqueid from 'lodash/uniqueId'; -import { Notification, TooltipProvider } from '@box/blueprint-web'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { Key, Selection } from 'react-aria-components'; import type { MetadataTemplateField } from '@box/metadata-editor'; @@ -96,13 +95,14 @@ import type { import type { BulkItemAction } from '../common/sub-header/BulkItemActionMenu'; import type { ContentPreviewProps } from '../content-preview'; import type { ContentUploaderProps } from '../content-uploader'; -import type { MetadataViewContainerProps } from './MetadataViewContainer'; +import type { ExternalFilterValues, MetadataViewContainerProps } from './MetadataViewContainer'; import '../common/fonts.scss'; import '../common/base.scss'; import '../common/modal.scss'; import './ContentExplorer.scss'; import { withBlueprintModernization } from '../common/withBlueprintModernization'; +import Providers from '../common/Providers'; const GRID_VIEW_MAX_COLUMNS = 7; const GRID_VIEW_MIN_COLUMNS = 1; @@ -127,6 +127,7 @@ export interface ContentExplorerProps { defaultView?: DefaultView; features?: FeatureConfig; fieldsToShow?: FieldsToShow; + hasProviders?: boolean; initialPage?: number; initialPageSize?: number; isLarge?: boolean; @@ -140,7 +141,10 @@ export interface ContentExplorerProps { measureRef?: (ref: Element | null) => void; messages?: StringMap; metadataQuery?: MetadataQuery; - metadataViewProps?: Omit; + metadataViewProps?: Omit< + MetadataViewContainerProps, + 'hasError' | 'currentCollection' | 'metadataTemplate' | 'onMetadataFilter' + >; onCreate?: (item: BoxItem) => void; onDelete?: (item: BoxItem) => void; onDownload?: (item: BoxItem) => void; @@ -183,6 +187,7 @@ type State = { isUploadModalOpen: boolean; markers: Array; metadataTemplate: MetadataTemplate; + metadataFilters: ExternalFilterValues; rootName: string; searchQuery: string; selected?: BoxItem; @@ -213,7 +218,7 @@ class ContentExplorer extends Component { store: LocalStore = new LocalStore(); - metadataQueryAPIHelper: MetadataQueryAPIHelper; + metadataQueryAPIHelper: MetadataQueryAPIHelper | MetadataQueryAPIHelperV2; static defaultProps = { rootFolderId: DEFAULT_ROOT, @@ -308,6 +313,7 @@ class ContentExplorer extends Component { isShareModalOpen: false, isUploadModalOpen: false, markers: [], + metadataFilters: {}, metadataTemplate: {}, rootName: '', selectedItemIds: new Set(), @@ -391,6 +397,7 @@ class ContentExplorer extends Component { * * @private * @param {Object} metadataQueryCollection - Metadata query response collection + * @param {Object} metadataTemplate - Metadata template object * @return {void} */ showMetadataQueryResultsSuccessCallback = ( @@ -442,7 +449,7 @@ class ContentExplorer extends Component { */ showMetadataQueryResults() { const { features, metadataQuery = {} }: ContentExplorerProps = this.props; - const { currentPageNumber, markers, sortBy, sortDirection }: State = this.state; + const { currentPageNumber, markers, metadataFilters, sortBy, sortDirection }: State = this.state; const metadataQueryClone = cloneDeep(metadataQuery); if (currentPageNumber === 0) { @@ -477,6 +484,12 @@ class ContentExplorer extends Component { ]; this.metadataQueryAPIHelper = new MetadataQueryAPIHelperV2(this.api); + this.metadataQueryAPIHelper.fetchMetadataQueryResults( + metadataQueryClone, + this.showMetadataQueryResultsSuccessCallback, + this.errorCallback, + metadataFilters, + ); } else { metadataQueryClone.order_by = [ { @@ -485,13 +498,12 @@ class ContentExplorer extends Component { }, ]; this.metadataQueryAPIHelper = new MetadataQueryAPIHelper(this.api); + this.metadataQueryAPIHelper.fetchMetadataQueryResults( + metadataQueryClone, + this.showMetadataQueryResultsSuccessCallback, + this.errorCallback, + ); } - - this.metadataQueryAPIHelper.fetchMetadataQueryResults( - metadataQueryClone, - this.showMetadataQueryResultsSuccessCallback, - this.errorCallback, - ); } /** @@ -1725,6 +1737,10 @@ class ContentExplorer extends Component { this.setState({ isMetadataSidePanelOpen: false }); }; + filterMetadata = (fields: ExternalFilterValues) => { + this.setState({ metadataFilters: fields }, this.refreshCollection); + }; + /** * Renders the file picker * @@ -1750,6 +1766,7 @@ class ContentExplorer extends Component { contentUploaderProps, defaultView, features, + hasProviders, isMedium, isSmall, isTouch, @@ -1819,193 +1836,191 @@ class ContentExplorer extends Component { /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ return ( - - - -
- -
-
- {!isDefaultViewMetadata && ( -
- )} - - - - - - {!isErrorView && ( -
- -
- )} -
- {isDefaultViewMetadata && isMetadataViewV2Feature && isMetadataSidePanelOpen && ( - + +
+ +
+
+ {!isDefaultViewMetadata && ( +
)} -
- {allowUpload && !!this.appElement ? ( - - ) : null} - {allowCreate && !!this.appElement ? ( - - ) : null} - {canDelete && selected && !!this.appElement ? ( - - ) : null} - {canRename && selected && !!this.appElement ? ( - - ) : null} - {canShare && selected && !!this.appElement ? ( - - ) : null} - {canPreview && selected && !!this.appElement ? ( - + + {!isErrorView && ( +
+ +
+ )} +
+ {isDefaultViewMetadata && isMetadataViewV2Feature && isMetadataSidePanelOpen && ( + - ) : null} + )}
- - + {allowUpload && !!this.appElement ? ( + + ) : null} + {allowCreate && !!this.appElement ? ( + + ) : null} + {canDelete && selected && !!this.appElement ? ( + + ) : null} + {canRename && selected && !!this.appElement ? ( + + ) : null} + {canShare && selected && !!this.appElement ? ( + + ) : null} + {canPreview && selected && !!this.appElement ? ( + + ) : null} +
+ ); /* eslint-enable jsx-a11y/no-static-element-interactions */ diff --git a/src/elements/content-explorer/MetadataQueryAPIHelper.ts b/src/elements/content-explorer/MetadataQueryAPIHelper.ts index 6f4fbbb3af..846b8762f9 100644 --- a/src/elements/content-explorer/MetadataQueryAPIHelper.ts +++ b/src/elements/content-explorer/MetadataQueryAPIHelper.ts @@ -5,6 +5,7 @@ import includes from 'lodash/includes'; import isArray from 'lodash/isArray'; import type { MetadataTemplateField } from '@box/metadata-editor'; import type { MetadataFieldType } from '@box/metadata-view'; + import API from '../../api'; import { areFieldValuesEqual, isEmptyValue, isMultiValuesField } from './utils'; @@ -16,7 +17,7 @@ import { METADATA_FIELD_TYPE_ENUM, METADATA_FIELD_TYPE_MULTISELECT, } from '../../common/constants'; -import { FIELD_NAME, FIELD_METADATA, FIELD_EXTENSION, FIELD_PERMISSIONS } from '../../constants'; +import { FIELD_ITEM_NAME, FIELD_METADATA, FIELD_EXTENSION, FIELD_PERMISSIONS } from '../../constants'; import type { MetadataQuery as MetadataQueryType, MetadataQueryResponseData } from '../../common/types/metadataQueries'; import type { @@ -28,6 +29,15 @@ import type { } from '../../common/types/metadata'; import type { ElementsXhrError, JSONPatchOperations } from '../../common/types/api'; import type { Collection, BoxItem } from '../../common/types/core'; +import { + getMimeTypeFilter, + getRangeFilter, + getSelectFilter, + getStringFilter, + mergeQueries, + mergeQueryParams, +} from './MetadataQueryBuilder'; +import type { ExternalFilterValues } from './MetadataViewContainer'; type SuccessCallback = (metadataQueryCollection: Collection, metadataTemplate: MetadataTemplate) => void; type ErrorCallback = (e: ElementsXhrError) => void; @@ -232,8 +242,10 @@ export default class MetadataQueryAPIHelper { metadataQuery: MetadataQueryType, successCallback: SuccessCallback, errorCallback: ErrorCallback, + fields?: ExternalFilterValues, ): Promise => { - this.metadataQuery = this.verifyQueryFields(metadataQuery); + this.metadataQuery = this.verifyQueryFields(metadataQuery, fields); + return this.queryMetadata() .then(this.getTemplateSchemaInfo) .then(this.getDataWithTypes) @@ -285,20 +297,114 @@ export default class MetadataQueryAPIHelper { .bulkUpdateMetadata(items, this.metadataTemplate, operations, successCallback, errorCallback); }; + buildMetadataQueryParams = (filters: ExternalFilterValues) => { + let argIndex = 0; + let queries: string[] = []; + let queryParams: { [key: string]: number | Date | string } = {}; + + if (filters) { + Object.keys(filters).forEach(key => { + const filter = filters[key]; + if (!filter) { + return; + } + + const { fieldType, value } = filter; + + switch (fieldType) { + case 'date': + case 'float': { + if (typeof value === 'object' && value !== null && 'range' in value) { + const result = getRangeFilter(value, key, argIndex); + queryParams = mergeQueryParams(queryParams, result.queryParams); + queries = mergeQueries(queries, result.queries); + argIndex += result.keysGenerated; + break; + } + break; + } + case 'enum': + case 'multiSelect': { + const arrayValue = Array.isArray(value) ? value.map(v => String(v)) : [String(value)]; + let result; + if (key === 'mimetype-filter') { + result = getMimeTypeFilter(arrayValue, key, argIndex); + } else { + result = getSelectFilter(arrayValue, key, argIndex); + } + queryParams = mergeQueryParams(queryParams, result.queryParams); + queries = mergeQueries(queries, result.queries); + argIndex += result.keysGenerated; + break; + } + + case 'string': { + if (value && value[0]) { + const result = getStringFilter(value[0], key, argIndex); + queryParams = mergeQueryParams(queryParams, result.queryParams); + queries = mergeQueries(queries, result.queries); + argIndex += result.keysGenerated; + } + break; + } + + default: + break; + } + }); + } + + const query = queries.reduce((acc, curr, index) => { + if (index > 0) { + acc += ` AND ${curr}`; + } else { + acc = curr; + } + return acc; + }, ''); + + return { + queryParams, + query, + }; + }; + + mergeQuery = (customQuery: string, filterQuery: string): string => { + if (!customQuery) { + return filterQuery; + } + if (!filterQuery) { + return customQuery; + } + // Merge queries with AND operator + return `${customQuery} AND ${filterQuery}`; + }; + /** * Verify that the metadata query has required fields and update it if necessary * For a file item, default fields included in the response are "type", "id", "etag" * * @param {MetadataQueryType} metadataQuery metadata query object + * @param {ExternalFilterValues} [fields] optional filter values to apply to the metadata query * @return {MetadataQueryType} updated metadata query object with required fields */ - verifyQueryFields = (metadataQuery: MetadataQueryType): MetadataQueryType => { + verifyQueryFields = (metadataQuery: MetadataQueryType, fields?: ExternalFilterValues): MetadataQueryType => { const clonedQuery = cloneDeep(metadataQuery); const clonedFields = isArray(clonedQuery.fields) ? clonedQuery.fields : []; + if (fields) { + const { query: filterQuery, queryParams: filteredQueryParams } = this.buildMetadataQueryParams(fields); + const { query: customQuery, query_params: customQueryParams } = clonedQuery; + const query = this.mergeQuery(customQuery, filterQuery); + const queryParams = mergeQueryParams(filteredQueryParams, customQueryParams); + if (query) { + clonedQuery.query = query; + clonedQuery.query_params = queryParams; + } + } // Make sure the query fields array has "name" field which is necessary to display info. - if (!clonedFields.includes(FIELD_NAME)) { - clonedFields.push(FIELD_NAME); + if (!clonedFields.includes(FIELD_ITEM_NAME)) { + clonedFields.push(FIELD_ITEM_NAME); } if (!clonedFields.includes(FIELD_EXTENSION)) { diff --git a/src/elements/content-explorer/MetadataQueryBuilder.ts b/src/elements/content-explorer/MetadataQueryBuilder.ts new file mode 100644 index 0000000000..7b1ff4ae6b --- /dev/null +++ b/src/elements/content-explorer/MetadataQueryBuilder.ts @@ -0,0 +1,159 @@ +import isNil from 'lodash/isNil'; + +type QueryResult = { + queryParams: { [key: string]: number | Date | string }; + queries: string[]; + keysGenerated: number; +}; + +// Custom type for range filters +type SimpleRangeType = { + range: { + gt: number | string; + lt: number | string; + }; +}; + +// Union type for filter values +type SimpleFilterValue = string[] | SimpleRangeType; + +export const mergeQueryParams = ( + targetParams: { [key: string]: number | Date | string }, + sourceParams: { [key: string]: number | Date | string }, +): { [key: string]: number | Date | string } => { + return { ...targetParams, ...sourceParams }; +}; + +export const mergeQueries = (targetQueries: string[], sourceQueries: string[]): string[] => { + return [...targetQueries, ...sourceQueries]; +}; + +const generateArgKey = (key: string, index: number): string => { + const purifyKey = key.replace(/[^\w]/g, '_'); + return `arg_${purifyKey}_${index}`; +}; + +const escapeValue = (value: string): string => value.replace(/([_%])/g, '\\$1'); + +export const getStringFilter = (filterValue: string, fieldKey: string, argIndexStart: number): QueryResult => { + let currentArgIndex = argIndexStart; + + const argKey = generateArgKey(fieldKey, (currentArgIndex += 1)); + return { + queryParams: { [argKey]: `%${escapeValue(filterValue)}%` }, + queries: [`(${fieldKey} ILIKE :${argKey})`], + keysGenerated: currentArgIndex - argIndexStart, + }; +}; + +const isInvalid = (value: number | string) => { + return isNil(value) || value === ''; +}; + +export const getRangeFilter = ( + filterValue: SimpleFilterValue, + fieldKey: string, + argIndexStart: number, +): QueryResult => { + let currentArgIndex = argIndexStart; + + if (filterValue && typeof filterValue === 'object' && 'range' in filterValue && filterValue.range) { + const { gt, lt } = filterValue.range; + const queryParams: { [key: string]: number | string } = {}; + const queries: string[] = []; + + if (!isInvalid(gt) && !isInvalid(lt)) { + // Both gt and lt: between values + const argKeyGt = generateArgKey(fieldKey, (currentArgIndex += 1)); + const argKeyLt = generateArgKey(fieldKey, (currentArgIndex += 1)); + queryParams[argKeyGt] = gt; + queryParams[argKeyLt] = lt; + queries.push(`(${fieldKey} >= :${argKeyGt} AND ${fieldKey} <= :${argKeyLt})`); + } else if (!isInvalid(gt)) { + // Only gt: greater than + const argKey = generateArgKey(fieldKey, (currentArgIndex += 1)); + queryParams[argKey] = gt; + queries.push(`(${fieldKey} >= :${argKey})`); + } else if (!isInvalid(lt)) { + // Only lt: less than + const argKey = generateArgKey(fieldKey, (currentArgIndex += 1)); + queryParams[argKey] = lt; + queries.push(`(${fieldKey} <= :${argKey})`); + } + + return { + queryParams, + queries, + keysGenerated: currentArgIndex - argIndexStart, + }; + } + return { + queryParams: {}, + queries: [], + keysGenerated: 0, + }; +}; + +export const getSelectFilter = (filterValue: string[], fieldKey: string, argIndexStart: number): QueryResult => { + if (!Array.isArray(filterValue) || filterValue.length === 0) { + return { + queryParams: {}, + queries: [], + keysGenerated: 0, + }; + } + + let currentArgIndex = argIndexStart; + + const multiSelectQueryParams = Object.fromEntries( + filterValue.map(value => { + currentArgIndex += 1; + return [generateArgKey(fieldKey, currentArgIndex), String(value)]; + }), + ); + + return { + queryParams: multiSelectQueryParams, + queries: [ + `(${fieldKey === 'mimetype-filter' ? 'item.extension' : fieldKey} HASANY (${Object.keys( + multiSelectQueryParams, + ) + .map(argKey => `:${argKey}`) + .join(', ')}))`, + ], + keysGenerated: currentArgIndex - argIndexStart, + }; +}; + +export const getMimeTypeFilter = (filterValue: string[], fieldKey: string, argIndexStart: number): QueryResult => { + if (!Array.isArray(filterValue) || filterValue.length === 0) { + return { + queryParams: {}, + queries: [], + keysGenerated: 0, + }; + } + + let currentArgIndex = argIndexStart; + + const multiSelectQueryParams = Object.fromEntries( + filterValue.map(value => { + currentArgIndex += 1; + // the item-type-selector is returning the extensions with the suffix 'Type', so we remove it for the query + return [ + generateArgKey(fieldKey, currentArgIndex), + String(value.endsWith('Type') ? value.slice(0, -4) : value), + ]; + }), + ); + + return { + queryParams: multiSelectQueryParams, + queries: [ + `(item.extension IN (${Object.keys(multiSelectQueryParams) + .map(argKey => `:${argKey}`) + .join(', ')}))`, + ], + keysGenerated: currentArgIndex - argIndexStart, + }; +}; diff --git a/src/elements/content-explorer/MetadataViewContainer.tsx b/src/elements/content-explorer/MetadataViewContainer.tsx index 73eafc9ba7..6ec4a69cec 100644 --- a/src/elements/content-explorer/MetadataViewContainer.tsx +++ b/src/elements/content-explorer/MetadataViewContainer.tsx @@ -1,20 +1,39 @@ import * as React from 'react'; -import type { EnumType, FloatType, MetadataFormFieldValue, RangeType } from '@box/metadata-filter'; -import { MetadataView, type MetadataViewProps } from '@box/metadata-view'; +import { useIntl } from 'react-intl'; +import { + EnumType, + FloatType, + MetadataFormFieldValue, + MetadataTemplateFieldOption, + RangeType, +} from '@box/metadata-filter'; +import { + MetadataView, + type FilterValues, + type MetadataViewProps, + type MetadataFieldType, + type Column, +} from '@box/metadata-view'; import { type Key } from '@react-types/shared'; +import cloneDeep from 'lodash/cloneDeep'; import { SortDescriptor } from 'react-aria-components'; +import { FIELD_ITEM_NAME } from '../../constants'; import type { Collection } from '../../common/types/core'; -import type { MetadataTemplate } from '../../common/types/metadata'; +import type { MetadataTemplate, MetadataTemplateField } from '../../common/types/metadata'; + +import messages from '../common/messages'; // 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< +export type ExternalFilterValues = Record< string, { + options?: FilterValues[string]['options'] | MetadataTemplateFieldOption[]; + fieldType: FilterValues[string]['fieldType'] | MetadataFieldType; value: ExternalMetadataFormFieldValue; } >; @@ -27,6 +46,8 @@ type ActionBarProps = Omit< onFilterSubmit?: (filterValues: ExternalFilterValues) => void; }; +const ITEM_FILTER_NAME = 'item_name'; + /** * Helper function to trim metadataFieldNamePrefix from column names * For example: 'metadata.enterprise_1515946.mdViewTemplate1.industry' -> 'industry' @@ -56,22 +77,36 @@ function transformInitialFilterValuesToInternal( ); } -function transformInternalFieldsToPublic( - fields: Record, -): ExternalFilterValues { - return Object.entries(fields).reduce((acc, [key, { value }]) => { - acc[key] = +export function convertFilterValuesToExternal(fields: FilterValues): ExternalFilterValues { + return Object.entries(fields).reduce((acc, [key, field]) => { + const { value, options, fieldType } = field; + + // Transform the value based on its type + const transformedValue: ExternalMetadataFormFieldValue = 'enum' in value && Array.isArray(value.enum) - ? { value: value.enum } - : { value: value as RangeType | FloatType }; + ? value.enum // Convert enum type to string array + : (value as RangeType | FloatType); // Keep range/float objects as-is + + acc[key === ITEM_FILTER_NAME ? FIELD_ITEM_NAME : key] = { + options, + fieldType, + value: transformedValue, + }; + return acc; }, {}); } +// Internal helper function for component use +function transformInternalFieldsToPublic(fields: FilterValues): ExternalFilterValues { + return convertFilterValuesToExternal(fields); +} + export interface MetadataViewContainerProps extends Omit { actionBarProps?: ActionBarProps; currentCollection: Collection; metadataTemplate: MetadataTemplate; + onMetadataFilter: (fields: ExternalFilterValues) => void; /* Internally controlled onSortChange prop for the MetadataView component. */ onSortChange?: (sortBy: Key, sortDirection: string) => void; } @@ -81,20 +116,65 @@ const MetadataViewContainer = ({ columns, currentCollection, metadataTemplate, + onMetadataFilter, onSortChange: onSortChangeInternal, + tableProps, ...rest }: MetadataViewContainerProps) => { + const { formatMessage } = useIntl(); const { items = [] } = currentCollection; - const { initialFilterValues: initialFilterValuesProp, onFilterSubmit: onFilterSubmitProp } = actionBarProps ?? {}; - - const filterGroups = React.useMemo( - () => [ + const { initialFilterValues: initialFilterValuesProp, onFilterSubmit } = actionBarProps ?? {}; + + const newColumns = React.useMemo(() => { + let clonedColumns = cloneDeep(columns); + + const hasItemNameField = clonedColumns.some((col: Column) => col.id === FIELD_ITEM_NAME); + + if (!hasItemNameField) { + clonedColumns = [ + { + allowsSorting: true, + id: FIELD_ITEM_NAME, + isItemMetadata: true, + isRowHeader: true, + minWidth: 250, + maxWidth: 250, + textValue: formatMessage(messages.name), + type: 'string', + }, + ...clonedColumns, + ]; + } + + return clonedColumns; + }, [columns, formatMessage]); + + const filterGroups = React.useMemo(() => { + const clonedTemplate = cloneDeep(metadataTemplate); + let fields = clonedTemplate?.fields || []; + + // Check if item_name field already exists to avoid duplicates + const hasItemNameField = fields.some((field: MetadataTemplateField) => field.key === ITEM_FILTER_NAME); + + if (!hasItemNameField) { + fields = [ + { + key: ITEM_FILTER_NAME, + displayName: formatMessage(messages.name), + type: 'string', + shouldRenderChip: true, + }, + ...fields, + ]; + } + + return [ { toggleable: true, filters: - metadataTemplate?.fields?.map(field => { + fields?.map(field => { return { - id: `${field.key}-filter`, + id: field.key, name: field.displayName, fieldType: field.type, options: field.options?.map(({ key }) => key) || [], @@ -102,38 +182,33 @@ const MetadataViewContainer = ({ }; }) || [], }, - ], - [metadataTemplate], - ); + ]; + }, [formatMessage, metadataTemplate]); - // 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 handleFilterSubmit = React.useCallback( + (fields: FilterValues) => { const transformed = transformInternalFieldsToPublic(fields); - onFilterSubmitProp(transformed); + onMetadataFilter(transformed); + if (onFilterSubmit) { + onFilterSubmit(transformed); + } }, - [onFilterSubmitProp], + [onFilterSubmit, onMetadataFilter], ); const transformedActionBarProps = React.useMemo(() => { return { ...actionBarProps, initialFilterValues, - onFilterSubmit, + onFilterSubmit: handleFilterSubmit, filterGroups, }; - }, [actionBarProps, initialFilterValues, onFilterSubmit, filterGroups]); - - // Extract the original tableProps.onSortChange from rest - const { tableProps, ...otherRest } = rest; - const onSortChangeExternal = tableProps?.onSortChange; + }, [actionBarProps, initialFilterValues, handleFilterSubmit, filterGroups]); // Create a wrapper function that calls both. The wrapper function should follow the signature of onSortChange from RAC const handleSortChange = React.useCallback( @@ -144,7 +219,7 @@ const MetadataViewContainer = ({ const trimmedColumn = trimMetadataFieldPrefix(String(column)); onSortChangeInternal(trimmedColumn, direction === 'ascending' ? 'ASC' : 'DESC'); } - + const onSortChangeExternal = tableProps?.onSortChange; // Then call the original customer-provided onSortChange if it exists // Accepts "ascending" / "descending" (https://react-spectrum.adobe.com/react-aria/Table.html) if (onSortChangeExternal) { @@ -154,7 +229,7 @@ const MetadataViewContainer = ({ }); } }, - [onSortChangeInternal, onSortChangeExternal], + [onSortChangeInternal, tableProps], ); // Create new tableProps with our wrapper function @@ -166,10 +241,10 @@ const MetadataViewContainer = ({ return ( ); }; diff --git a/src/elements/content-explorer/__tests__/Content.test.tsx b/src/elements/content-explorer/__tests__/Content.test.tsx index 920456e438..207a663e05 100644 --- a/src/elements/content-explorer/__tests__/Content.test.tsx +++ b/src/elements/content-explorer/__tests__/Content.test.tsx @@ -30,6 +30,7 @@ const mockProps: ContentProps = { onItemRename: jest.fn(), onItemSelect: jest.fn(), onItemShare: jest.fn(), + onMetadataFilter: jest.fn(), onMetadataUpdate: jest.fn(), onSortChange: jest.fn(), portalElement: null, diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index fbc2ae2479..2433d4b7c4 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -468,10 +468,9 @@ describe('elements/content-explorer/ContentExplorer', () => { })), ]; const defaultView = 'metadata'; - const metadataViewV2ElementProps = { + const metadataViewV2ElementProps: Partial = { metadataViewProps: { columns, - metadataTemplate: mockSchema, tableProps: { isSelectAllEnabled: true, }, @@ -496,9 +495,7 @@ describe('elements/content-explorer/ContentExplorer', () => { expect(screen.queryByRole('button', { name: 'Switch to Grid View' })).toBeInTheDocument(); }); - await waitFor(() => { - expect(screen.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); - }); + expect(screen.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); const selectAllCheckbox = screen.getByLabelText('Select all'); await userEvent.click(selectAllCheckbox); diff --git a/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts b/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts index 9fa66481c6..9dc5733d98 100644 --- a/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts +++ b/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts @@ -8,9 +8,9 @@ import { JSON_PATCH_OP_REPLACE, JSON_PATCH_OP_TEST, } from '../../../common/constants'; -import { FIELD_METADATA, FIELD_NAME, FIELD_EXTENSION, FIELD_PERMISSIONS } from '../../../constants'; +import { FIELD_METADATA, FIELD_ITEM_NAME, FIELD_EXTENSION, FIELD_PERMISSIONS } from '../../../constants'; -describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { +describe('elements/content-explorer/MetadataQueryAPIHelper', () => { let metadataQueryAPIHelper; const templateScope = 'enterprise_12345'; const templateKey = 'awesomeTemplate'; @@ -193,7 +193,7 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { query: 'query', query_params: {}, fields: [ - FIELD_NAME, + FIELD_ITEM_NAME, 'metadata.enterprise_1234.templateKey.type', 'metadata.enterprise_1234.templateKey.year', 'metadata.enterprise_1234.templateKey.approved', @@ -225,6 +225,29 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { test('should return empty object when instance is not found', () => { expect(metadataQueryAPIHelper.flattenMetadata(undefined)).toEqual({}); }); + + test('should return fields even when template fields are empty', () => { + metadataQueryAPIHelper.metadataTemplate = { ...template, fields: [] }; + const result = metadataQueryAPIHelper.flattenMetadata(entries[0].metadata); + expect(result.enterprise.fields).toHaveLength(3); + expect(result.enterprise.fields[0].type).toBeUndefined(); + }); + + test('should handle missing template field gracefully', () => { + const metadataWithMissingField = { + [templateScope]: { + [templateKey]: { + $id: metadataInstanceId1, + type: 'bill', + // year field is missing + approved: 'yes', + }, + }, + }; + const result = metadataQueryAPIHelper.flattenMetadata(metadataWithMissingField); + expect(result.enterprise.fields).toHaveLength(3); + expect(result.enterprise.fields.find(f => f.key.includes('year'))?.value).toBeUndefined(); + }); }); describe('getDataWithTypes()', () => { @@ -234,6 +257,13 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { expect(result).toEqual(dataWithTypes); expect(metadataQueryAPIHelper.metadataTemplate).toEqual(template); }); + + test('should handle undefined template schema response', () => { + metadataQueryAPIHelper.metadataQueryResponseData = metadataQueryResponse; + const result = metadataQueryAPIHelper.getDataWithTypes(undefined); + expect(result).toEqual(dataWithTypes); + expect(metadataQueryAPIHelper.metadataTemplate).toBeUndefined(); + }); }); describe('getTemplateSchemaInfo()', () => { @@ -253,6 +283,20 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { expect(result).toBe(undefined); expect(metadataQueryAPIHelper.metadataQueryResponseData).toEqual(emptyEntriesResponse); }); + + test('should handle response with null entries', async () => { + const nullEntriesResponse = { entries: null, next_marker: nextMarker }; + const result = await metadataQueryAPIHelper.getTemplateSchemaInfo(nullEntriesResponse); + expect(getSchemaByTemplateKeyFunc).not.toHaveBeenCalled(); + expect(result).toBe(undefined); + }); + + test('should handle response with undefined entries', async () => { + const undefinedEntriesResponse = { next_marker: nextMarker }; + const result = await metadataQueryAPIHelper.getTemplateSchemaInfo(undefinedEntriesResponse); + expect(getSchemaByTemplateKeyFunc).not.toHaveBeenCalled(); + expect(result).toBe(undefined); + }); }); describe('queryMetadata()', () => { @@ -266,6 +310,15 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { { forceFetch: true }, ); }); + + test('should handle API errors properly', async () => { + const error = new Error('API Error'); + queryMetadataFunc.mockImplementationOnce((query, resolve, reject) => { + reject(error); + }); + + await expect(metadataQueryAPIHelper.queryMetadata()).rejects.toThrow('API Error'); + }); }); describe('fetchMetadataQueryResults()', () => { @@ -301,6 +354,17 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { expect(successCallback).not.toHaveBeenCalled(); expect(errorCallback).toBeCalledWith(err); }); + + test('should handle query metadata errors', async () => { + const err = new Error('Query failed'); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadataQueryAPIHelper.queryMetadata = jest.fn().mockReturnValueOnce(Promise.reject(err)); + + await metadataQueryAPIHelper.fetchMetadataQueryResults(mdQuery, successCallback, errorCallback); + expect(errorCallback).toBeCalledWith(err); + expect(successCallback).not.toHaveBeenCalled(); + }); }); describe('createJSONPatchOperations()', () => { @@ -345,6 +409,30 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { const expectedResponse = ['type', 'year', 'approved']; expect(metadataQueryAPIHelper.getMetadataQueryFields()).toEqual(expectedResponse); }); + + test('should handle query with no metadata fields', () => { + metadataQueryAPIHelper.metadataQuery = { + ...mdQuery, + fields: [FIELD_ITEM_NAME, 'created_at'], + }; + expect(metadataQueryAPIHelper.getMetadataQueryFields()).toEqual([]); + }); + + test('should handle query with empty fields array', () => { + metadataQueryAPIHelper.metadataQuery = { + ...mdQuery, + fields: [], + }; + expect(metadataQueryAPIHelper.getMetadataQueryFields()).toEqual([]); + }); + + test('should handle query with undefined fields', () => { + metadataQueryAPIHelper.metadataQuery = { + ...mdQuery, + fields: undefined, + }; + expect(metadataQueryAPIHelper.getMetadataQueryFields()).toEqual([]); + }); }); describe('updateMetadata()', () => { @@ -396,21 +484,21 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { from: 'enterprise_1234.templateKey', query: 'query', query_params: {}, - fields: [FIELD_NAME, 'metadata.enterprise_1234.templateKey.type'], + fields: [FIELD_ITEM_NAME, 'metadata.enterprise_1234.templateKey.type'], }; const mdQueryWithoutExtensionField = { ancestor_folder_id: '672838458', from: 'enterprise_1234.templateKey', query: 'query', query_params: {}, - fields: [FIELD_NAME, 'metadata.enterprise_1234.templateKey.type'], + fields: [FIELD_ITEM_NAME, 'metadata.enterprise_1234.templateKey.type'], }; const mdQueryWithBothFields = { ancestor_folder_id: '672838458', from: 'enterprise_1234.templateKey', query: 'query', query_params: {}, - fields: [FIELD_NAME, FIELD_EXTENSION, 'metadata.enterprise_1234.templateKey.type'], + fields: [FIELD_ITEM_NAME, FIELD_EXTENSION, 'metadata.enterprise_1234.templateKey.type'], }; test.each` index | metadataQuery @@ -424,7 +512,7 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { ({ index, metadataQuery }) => { const updatedMetadataQuery = metadataQueryAPIHelper.verifyQueryFields(metadataQuery); expect(isArray(updatedMetadataQuery.fields)).toBe(true); - expect(includes(updatedMetadataQuery.fields, FIELD_NAME)).toBe(true); + expect(includes(updatedMetadataQuery.fields, FIELD_ITEM_NAME)).toBe(true); expect(includes(updatedMetadataQuery.fields, FIELD_EXTENSION)).toBe(true); expect(includes(updatedMetadataQuery.fields, FIELD_PERMISSIONS)).toBe(true); @@ -432,7 +520,7 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { // Verify "name", "extension" and "permission" are added to pre-existing fields expect(updatedMetadataQuery.fields).toEqual([ ...mdQueryWithoutNameField.fields, - FIELD_NAME, + FIELD_ITEM_NAME, FIELD_EXTENSION, FIELD_PERMISSIONS, ]); @@ -453,5 +541,330 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { } }, ); + + test('should handle query with non-array fields', () => { + const mdQueryWithNonArrayFields = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + query: 'query', + query_params: {}, + fields: 'not-an-array', + }; + + const updatedMetadataQuery = metadataQueryAPIHelper.verifyQueryFields(mdQueryWithNonArrayFields); + expect(isArray(updatedMetadataQuery.fields)).toBe(true); + expect(includes(updatedMetadataQuery.fields, FIELD_ITEM_NAME)).toBe(true); + expect(includes(updatedMetadataQuery.fields, FIELD_EXTENSION)).toBe(true); + }); + }); + + describe('buildMDQueryParams()', () => { + test('should return empty result when no filters provided', () => { + const result = metadataQueryAPIHelper.buildMetadataQueryParams({}); + expect(result).toEqual({ + queryParams: {}, + query: '', + }); + }); + + test('should return empty result when filters is null', () => { + const result = metadataQueryAPIHelper.buildMetadataQueryParams(null); + expect(result).toEqual({ + queryParams: {}, + query: '', + }); + }); + + test('should handle date/float field with greater than filter', () => { + const filters = { + year: { + fieldType: 'float', + value: { range: { gt: 2020 } }, + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe('(year >= :arg_year_1)'); + expect(result.queryParams.arg_year_1).toBe(2020); + }); + + test('should handle date/float field with less than filter', () => { + const filters = { + year: { + fieldType: 'date', + value: { range: { lt: 2023 } }, + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe('(year <= :arg_year_1)'); + expect(result.queryParams.arg_year_1).toBe(2023); + }); + + test('should handle date/float field with range array filter', () => { + const filters = { + year: { + fieldType: 'float', + value: { range: { gt: 2020, lt: 2023 } }, + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe('(year >= :arg_year_1 AND year <= :arg_year_2)'); + expect(result.queryParams.arg_year_1).toBe(2020); + expect(result.queryParams.arg_year_2).toBe(2023); + }); + + test('should handle enum field with single value', () => { + const filters = { + status: { + fieldType: 'enum', + value: 'active', + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe('(status HASANY (:arg_status_1))'); + expect(result.queryParams.arg_status_1).toBe('active'); + }); + + test('should handle enum field with multiple values', () => { + const filters = { + status: { + fieldType: 'enum', + value: ['active', 'pending'], + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe('(status HASANY (:arg_status_1, :arg_status_2))'); + expect(result.queryParams.arg_status_1).toBe('active'); + expect(result.queryParams.arg_status_2).toBe('pending'); + }); + + test('should handle multiSelect field', () => { + const filters = { + tags: { + fieldType: 'multiSelect', + value: ['tag1', 'tag2'], + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe('(tags HASANY (:arg_tags_1, :arg_tags_2))'); + expect(result.queryParams.arg_tags_1).toBe('tag1'); + expect(result.queryParams.arg_tags_2).toBe('tag2'); + }); + + test('should handle string field with search value', () => { + const filters = { + name: { + fieldType: 'string', + value: ['search term'], + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe('(name ILIKE :arg_name_1)'); + expect(result.queryParams.arg_name_1).toBe('%search term%'); + }); + + test('should handle mimetype filter specifically', () => { + const filters = { + 'mimetype-filter': { + fieldType: 'enum', + value: ['pdf', 'doc'], + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe('(item.extension IN (:arg_mimetype_filter_1, :arg_mimetype_filter_2))'); + expect(result.queryParams.arg_mimetype_filter_1).toBe('pdf'); + expect(result.queryParams.arg_mimetype_filter_2).toBe('doc'); + }); + + test('should handle multiple filters of different types', () => { + const filters = { + year: { + fieldType: 'float', + value: { range: { gt: 2020 } }, + }, + status: { + fieldType: 'enum', + value: ['active'], + }, + name: { + fieldType: 'string', + value: ['search'], + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe( + '(year >= :arg_year_1) AND (status HASANY (:arg_status_2)) AND (name ILIKE :arg_name_3)', + ); + expect(Object.keys(result.queryParams)).toHaveLength(3); + }); + + test('should handle filter with null/undefined value', () => { + const filters = { + field: { + fieldType: 'string', + value: null, + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe(''); + expect(Object.keys(result.queryParams)).toHaveLength(0); + }); + + test('should handle filter with empty string value', () => { + const filters = { + field: { + fieldType: 'string', + value: '', + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe(''); + expect(Object.keys(result.queryParams)).toHaveLength(0); + }); + + test('should handle unknown field type with array value', () => { + const filters = { + field: { + fieldType: 'unknown', + value: ['value1', 'value2'], + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBeFalsy(); + expect(result.queryParams.arg_field_1).toBeUndefined(); + expect(result.queryParams.arg_field_2).toBeUndefined(); + }); + test('should handle empty array values for enum/multiSelect', () => { + const filters = { + status: { + fieldType: 'enum', + value: [], + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe(''); + expect(Object.keys(result.queryParams)).toHaveLength(0); + }); + + test('should handle empty string array for string field', () => { + const filters = { + name: { + fieldType: 'string', + value: [''], + }, + }; + + const result = metadataQueryAPIHelper.buildMetadataQueryParams(filters); + expect(result.query).toBe(''); + expect(Object.keys(result.queryParams)).toHaveLength(0); + }); + }); + + describe('verifyQueryFields with filters', () => { + test('should build query and query_params when filters are provided', () => { + const metadataQuery = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + fields: [FIELD_ITEM_NAME], + }; + + const filters = { + status: { + fieldType: 'enum', + value: ['active'], + }, + }; + + const result = metadataQueryAPIHelper.verifyQueryFields(metadataQuery, filters); + + expect(result.query).toBe('(status HASANY (:arg_status_1))'); + expect(result.query_params).toEqual({ + arg_status_1: 'active', + }); + expect(result.fields).toContain(FIELD_ITEM_NAME); + expect(result.fields).toContain(FIELD_EXTENSION); + }); + + test('should handle multiple filters with AND logic', () => { + const metadataQuery = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + fields: [FIELD_ITEM_NAME], + }; + + const filters = { + status: { + fieldType: 'enum', + value: ['active'], + }, + year: { + fieldType: 'float', + value: { range: { gt: 2020 } }, + }, + }; + + const result = metadataQueryAPIHelper.verifyQueryFields(metadataQuery, filters); + + expect(result.query).toContain('AND'); + expect(result.query).toContain('HASANY'); + expect(result.query).toContain('>='); + expect(Object.keys(result.query_params)).toHaveLength(2); + }); + + test('should merge existing query_params with filter query params', () => { + const metadataQuery = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + fields: [FIELD_ITEM_NAME], + query: '(existing_field = :existing_param)', + query_params: { + existing_param: 'existing_value', + }, + }; + + const filters = { + status: { + fieldType: 'enum', + value: ['active'], + }, + }; + + const result = metadataQueryAPIHelper.verifyQueryFields(metadataQuery, filters); + + expect(result.query).toBe('(existing_field = :existing_param) AND (status HASANY (:arg_status_1))'); + expect(result.query_params).toEqual({ + existing_param: 'existing_value', + arg_status_1: 'active', + }); + expect(result.fields).toContain(FIELD_ITEM_NAME); + expect(result.fields).toContain(FIELD_EXTENSION); + }); + + test('should not modify query when no filters provided', () => { + const metadataQuery = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + fields: [FIELD_ITEM_NAME], + }; + + const result = metadataQueryAPIHelper.verifyQueryFields(metadataQuery); + + expect(result.query).toBeUndefined(); + expect(result.query_params).toBeUndefined(); + expect(result.fields).toContain(FIELD_ITEM_NAME); + expect(result.fields).toContain(FIELD_EXTENSION); + }); }); }); diff --git a/src/elements/content-explorer/__tests__/MetadataQueryBuilder.test.ts b/src/elements/content-explorer/__tests__/MetadataQueryBuilder.test.ts new file mode 100644 index 0000000000..4a70ee1117 --- /dev/null +++ b/src/elements/content-explorer/__tests__/MetadataQueryBuilder.test.ts @@ -0,0 +1,419 @@ +import { + mergeQueryParams, + mergeQueries, + getStringFilter, + getRangeFilter, + getSelectFilter, + getMimeTypeFilter, +} from '../MetadataQueryBuilder'; + +describe('elements/content-explorer/MetadataQueryBuilder', () => { + describe('mergeQueryParams', () => { + test('should merge two objects', () => { + const target = { key1: 'value1' }; + const source = { key2: 'value2' }; + const result = mergeQueryParams(target, source); + expect(result).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + test('should override target values with source values', () => { + const target = { key1: 'value1', key2: 'old' }; + const source = { key2: 'new', key3: 'value3' }; + const result = mergeQueryParams(target, source); + expect(result).toEqual({ key1: 'value1', key2: 'new', key3: 'value3' }); + }); + + test('should return source when target is empty', () => { + const target = {}; + const source = { key1: 'value1' }; + const result = mergeQueryParams(target, source); + expect(result).toEqual({ key1: 'value1' }); + }); + + test('should return target when source is empty', () => { + const target = { key1: 'value1' }; + const source = {}; + const result = mergeQueryParams(target, source); + expect(result).toEqual({ key1: 'value1' }); + }); + + test('should return empty object when both are empty', () => { + const result = mergeQueryParams({}, {}); + expect(result).toEqual({}); + }); + }); + + describe('mergeQueries', () => { + test('should merge two arrays', () => { + const target = ['query1']; + const source = ['query2']; + const result = mergeQueries(target, source); + expect(result).toEqual(['query1', 'query2']); + }); + + test('should handle empty target array', () => { + const target: string[] = []; + const source = ['query1', 'query2']; + const result = mergeQueries(target, source); + expect(result).toEqual(['query1', 'query2']); + }); + + test('should handle empty source array', () => { + const target = ['query1', 'query2']; + const source: string[] = []; + const result = mergeQueries(target, source); + expect(result).toEqual(['query1', 'query2']); + }); + + test('should handle both empty arrays', () => { + const result = mergeQueries([], []); + expect(result).toEqual([]); + }); + }); + + describe('getStringFilter', () => { + test('should generate string filter with ILIKE query', () => { + const result = getStringFilter('test', 'field_name', 0); + expect(result).toEqual({ + queryParams: { arg_field_name_1: '%test%' }, + queries: ['(field_name ILIKE :arg_field_name_1)'], + keysGenerated: 1, + }); + }); + + test('should escape special characters in value', () => { + const result = getStringFilter('test_value%123', 'field_name', 0); + expect(result).toEqual({ + queryParams: { arg_field_name_1: '%test\\_value\\%123%' }, + queries: ['(field_name ILIKE :arg_field_name_1)'], + keysGenerated: 1, + }); + }); + + test('should use correct arg index', () => { + const result = getStringFilter('test', 'field_name', 5); + expect(result).toEqual({ + queryParams: { arg_field_name_6: '%test%' }, + queries: ['(field_name ILIKE :arg_field_name_6)'], + keysGenerated: 1, + }); + }); + }); + + describe('getRangeFilter', () => { + test('should generate range filter with both gt and lt', () => { + const filterValue = { range: { gt: 10, lt: 20 } }; + const result = getRangeFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: { arg_field_name_1: 10, arg_field_name_2: 20 }, + queries: ['(field_name >= :arg_field_name_1 AND field_name <= :arg_field_name_2)'], + keysGenerated: 2, + }); + }); + + test('should generate range filter with only gt', () => { + const filterValue = { range: { gt: 10, lt: '' } }; + const result = getRangeFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: { arg_field_name_1: 10 }, + queries: ['(field_name >= :arg_field_name_1)'], + keysGenerated: 1, + }); + }); + + test('should generate range filter with only lt', () => { + const filterValue = { range: { gt: '', lt: 20 } }; + const result = getRangeFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: { arg_field_name_1: 20 }, + queries: ['(field_name <= :arg_field_name_1)'], + keysGenerated: 1, + }); + }); + + test('should handle null values in range', () => { + const filterValue = { range: { gt: null, lt: 20 } }; + const result = getRangeFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: { arg_field_name_1: 20 }, + queries: ['(field_name <= :arg_field_name_1)'], + keysGenerated: 1, + }); + }); + + test('should handle undefined values in range', () => { + const filterValue = { range: { gt: undefined, lt: 20 } }; + const result = getRangeFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: { arg_field_name_1: 20 }, + queries: ['(field_name <= :arg_field_name_1)'], + keysGenerated: 1, + }); + }); + + test('should handle zero values in range', () => { + const filterValue = { range: { gt: 0, lt: 100 } }; + const result = getRangeFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: { arg_field_name_1: 0, arg_field_name_2: 100 }, + queries: ['(field_name >= :arg_field_name_1 AND field_name <= :arg_field_name_2)'], + keysGenerated: 2, + }); + }); + + test('should return empty result for invalid filter value', () => { + const result = getRangeFilter(null, 'field_name', 0); + expect(result).toEqual({ + queryParams: {}, + queries: [], + keysGenerated: 0, + }); + }); + + test('should return empty result for filter value without range', () => { + const filterValue = { someOtherProperty: 'value' } as unknown as + | string[] + | { range: { gt: number | string; lt: number | string } }; + const result = getRangeFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: {}, + queries: [], + keysGenerated: 0, + }); + }); + + test('should return empty result for empty range', () => { + const filterValue = { range: { gt: '', lt: '' } }; + const result = getRangeFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: {}, + queries: [], + keysGenerated: 0, + }); + }); + + test('should return empty result for null range', () => { + const filterValue = { range: null }; + const result = getRangeFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: {}, + queries: [], + keysGenerated: 0, + }); + }); + + test('should use correct arg index', () => { + const filterValue = { range: { gt: 10, lt: 20 } }; + const result = getRangeFilter(filterValue, 'field_name', 5); + expect(result).toEqual({ + queryParams: { arg_field_name_6: 10, arg_field_name_7: 20 }, + queries: ['(field_name >= :arg_field_name_6 AND field_name <= :arg_field_name_7)'], + keysGenerated: 2, + }); + }); + }); + + describe('getSelectFilter', () => { + test('should generate select filter for multiple values', () => { + const filterValue = ['value1', 'value2', 'value3']; + const result = getSelectFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: { + arg_field_name_1: 'value1', + arg_field_name_2: 'value2', + arg_field_name_3: 'value3', + }, + queries: ['(field_name HASANY (:arg_field_name_1, :arg_field_name_2, :arg_field_name_3))'], + keysGenerated: 3, + }); + }); + + test('should handle mimetype-filter field key specially', () => { + const filterValue = ['pdf', 'doc']; + const result = getSelectFilter(filterValue, 'mimetype-filter', 0); + expect(result).toEqual({ + queryParams: { + arg_mimetype_filter_1: 'pdf', + arg_mimetype_filter_2: 'doc', + }, + queries: ['(item.extension HASANY (:arg_mimetype_filter_1, :arg_mimetype_filter_2))'], + keysGenerated: 2, + }); + }); + + test('should handle single value array', () => { + const filterValue = ['single_value']; + const result = getSelectFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: { arg_field_name_1: 'single_value' }, + queries: ['(field_name HASANY (:arg_field_name_1))'], + keysGenerated: 1, + }); + }); + + test('should handle numeric values converted to strings', () => { + const filterValue = ['123', '456']; + const result = getSelectFilter(filterValue, 'field_name', 0); + expect(result).toEqual({ + queryParams: { + arg_field_name_1: '123', + arg_field_name_2: '456', + }, + queries: ['(field_name HASANY (:arg_field_name_1, :arg_field_name_2))'], + keysGenerated: 2, + }); + }); + + test('should return empty result for empty array', () => { + const result = getSelectFilter([], 'field_name', 0); + expect(result).toEqual({ + queryParams: {}, + queries: [], + keysGenerated: 0, + }); + }); + + test('should return empty result for null/undefined', () => { + const result = getSelectFilter(null, 'field_name', 0); + expect(result).toEqual({ + queryParams: {}, + queries: [], + keysGenerated: 0, + }); + }); + + test('should use correct arg index', () => { + const filterValue = ['value1']; + const result = getSelectFilter(filterValue, 'field_name', 5); + expect(result).toEqual({ + queryParams: { arg_field_name_6: 'value1' }, + queries: ['(field_name HASANY (:arg_field_name_6))'], + keysGenerated: 1, + }); + }); + + test('should handle field names with special characters', () => { + const filterValue = ['value1', 'value2']; + const result = getSelectFilter(filterValue, 'field-name.with/special_chars', 0); + expect(result).toEqual({ + queryParams: { + arg_field_name_with_special_chars_1: 'value1', + arg_field_name_with_special_chars_2: 'value2', + }, + queries: [ + '(field-name.with/special_chars HASANY (:arg_field_name_with_special_chars_1, :arg_field_name_with_special_chars_2))', + ], + keysGenerated: 2, + }); + }); + }); + + describe('getMimeTypeFilter', () => { + test('should generate mime type filter and remove "Type" suffix', () => { + const filterValue = ['pdfType', 'docType', 'txtType']; + const result = getMimeTypeFilter(filterValue, 'mimetype', 0); + expect(result).toEqual({ + queryParams: { + arg_mimetype_1: 'pdf', + arg_mimetype_2: 'doc', + arg_mimetype_3: 'txt', + }, + queries: ['(item.extension IN (:arg_mimetype_1, :arg_mimetype_2, :arg_mimetype_3))'], + keysGenerated: 3, + }); + }); + + test('should handle values without "Type" suffix', () => { + const filterValue = ['pdf', 'doc']; + const result = getMimeTypeFilter(filterValue, 'mimetype', 0); + expect(result).toEqual({ + queryParams: { + arg_mimetype_1: 'pdf', + arg_mimetype_2: 'doc', + }, + queries: ['(item.extension IN (:arg_mimetype_1, :arg_mimetype_2))'], + keysGenerated: 2, + }); + }); + + test('should handle mixed values with and without "Type" suffix', () => { + const filterValue = ['pdfType', 'doc', 'txtType']; + const result = getMimeTypeFilter(filterValue, 'mimetype', 0); + expect(result).toEqual({ + queryParams: { + arg_mimetype_1: 'pdf', + arg_mimetype_2: 'doc', + arg_mimetype_3: 'txt', + }, + queries: ['(item.extension IN (:arg_mimetype_1, :arg_mimetype_2, :arg_mimetype_3))'], + keysGenerated: 3, + }); + }); + + test('should handle single value array', () => { + const filterValue = ['pdfType']; + const result = getMimeTypeFilter(filterValue, 'mimetype', 0); + expect(result).toEqual({ + queryParams: { arg_mimetype_1: 'pdf' }, + queries: ['(item.extension IN (:arg_mimetype_1))'], + keysGenerated: 1, + }); + }); + + test('should handle numeric values converted to strings', () => { + const filterValue = ['123', '456']; + const result = getMimeTypeFilter(filterValue, 'mimetype', 0); + expect(result).toEqual({ + queryParams: { + arg_mimetype_1: '123', + arg_mimetype_2: '456', + }, + queries: ['(item.extension IN (:arg_mimetype_1, :arg_mimetype_2))'], + keysGenerated: 2, + }); + }); + + test('should return empty result for empty array', () => { + const result = getMimeTypeFilter([], 'mimetype', 0); + expect(result).toEqual({ + queryParams: {}, + queries: [], + keysGenerated: 0, + }); + }); + + test('should return empty result for null/undefined', () => { + const result = getMimeTypeFilter(null, 'mimetype', 0); + expect(result).toEqual({ + queryParams: {}, + queries: [], + keysGenerated: 0, + }); + }); + + test('should use correct arg index', () => { + const filterValue = ['pdfType']; + const result = getMimeTypeFilter(filterValue, 'mimetype', 5); + expect(result).toEqual({ + queryParams: { arg_mimetype_6: 'pdf' }, + queries: ['(item.extension IN (:arg_mimetype_6))'], + keysGenerated: 1, + }); + }); + + test('should handle field names with special characters', () => { + const filterValue = ['pdfType', 'docType']; + const result = getMimeTypeFilter(filterValue, 'mime-type.with/special_chars', 0); + expect(result).toEqual({ + queryParams: { + arg_mime_type_with_special_chars_1: 'pdf', + arg_mime_type_with_special_chars_2: 'doc', + }, + queries: [ + '(item.extension IN (:arg_mime_type_with_special_chars_1, :arg_mime_type_with_special_chars_2))', + ], + keysGenerated: 2, + }); + }); + }); +}); diff --git a/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx index 0fcaceb8e6..142cd6233d 100644 --- a/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx +++ b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx @@ -3,18 +3,34 @@ import * as React from 'react'; 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'; +import MetadataViewContainer, { + type MetadataViewContainerProps, + convertFilterValuesToExternal, + type ExternalFilterValues, +} from '../MetadataViewContainer'; describe('elements/content-explorer/MetadataViewContainer', () => { const mockItems = [ - { id: '1', name: 'File 1.txt', type: 'file' }, - { id: '2', name: 'File 2.pdf', type: 'file' }, + { + id: '1', + name: 'File 1.txt', + type: 'file', + 'item.name': 'File 1.txt', + industry: 'tech', + }, + { + id: '2', + name: 'File 2.pdf', + type: 'file', + 'item.name': 'File 2.pdf', + industry: 'finance', + }, ]; const mockMetadataTemplateFields: MetadataTemplateField[] = [ { id: 'field1', - key: ' name', + key: 'item.name', displayName: 'Name', type: 'string', }, @@ -28,6 +44,22 @@ describe('elements/content-explorer/MetadataViewContainer', () => { { key: 'finance', id: 'finance1' }, ], }, + { + id: 'field3', + key: 'price', + displayName: 'Price', + type: 'float', + }, + { + id: 'field4', + key: 'category', + displayName: 'Category', + type: 'multiSelect', + options: [ + { key: 'category1', id: 'cat1' }, + { key: 'category2', id: 'cat2' }, + ], + }, ]; const mockMetadataTemplate: MetadataTemplate = { @@ -49,7 +81,7 @@ describe('elements/content-explorer/MetadataViewContainer', () => { columns: [ { textValue: 'Name', - id: 'name', + id: 'item.name', type: 'string', allowsSorting: true, minWidth: 250, @@ -66,17 +98,22 @@ describe('elements/content-explorer/MetadataViewContainer', () => { }, ], metadataTemplate: mockMetadataTemplate, + onMetadataFilter: jest.fn(), }; const renderComponent = (props: Partial = {}) => { return render(); }; + beforeEach(() => { + jest.clearAllMocks(); + }); + test('should render MetadataView component', () => { renderComponent(); expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Name' })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Name' })).toHaveLength(2); // One in filter bar, one in table header expect(screen.getByRole('button', { name: 'Industry' })).toBeInTheDocument(); expect(screen.getByText('File 1.txt')).toBeInTheDocument(); expect(screen.getByText('File 2.pdf')).toBeInTheDocument(); @@ -101,7 +138,11 @@ describe('elements/content-explorer/MetadataViewContainer', () => { ], }; - renderComponent({ metadataTemplate: template, actionBarProps: { onFilterSubmit } }); + renderComponent({ + metadataTemplate: template, + actionBarProps: { onFilterSubmit }, + onMetadataFilter: jest.fn(), + }); await userEvent().click(screen.getByRole('button', { name: /Contact Role/ })); await userEvent().click(within(screen.getByRole('menu')).getByRole('menuitemcheckbox', { name: 'Developer' })); @@ -112,7 +153,370 @@ describe('elements/content-explorer/MetadataViewContainer', () => { 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']); + + expect(firstCall.role.value).toEqual(['Developer']); + expect(secondCall.role.value).toEqual(['Developer', 'Marketing']); + }); + + test('should call onMetadataFilter and onFilterSubmit when filter is submitted', async () => { + const onFilterSubmit = jest.fn(); + const onMetadataFilter = jest.fn(); + const template: MetadataTemplate = { + ...mockMetadataTemplate, + fields: [ + { + id: 'field1', + key: 'status', + displayName: 'Status', + type: 'enum', + options: [ + { id: 's1', key: 'Active' }, + { id: 's2', key: 'Inactive' }, + ], + }, + ], + }; + + renderComponent({ + metadataTemplate: template, + actionBarProps: { onFilterSubmit }, + onMetadataFilter, + }); + + await userEvent().click(screen.getByRole('button', { name: /Status/ })); + await userEvent().click(within(screen.getByRole('menu')).getByRole('menuitemcheckbox', { name: 'Active' })); + + await waitFor(() => { + expect(onMetadataFilter).toHaveBeenCalledTimes(1); + expect(onFilterSubmit).toHaveBeenCalledTimes(1); + }); + + const filterCall = onMetadataFilter.mock.calls[0][0]; + const submitCall = onFilterSubmit.mock.calls[0][0]; + + expect(filterCall.status.value).toEqual(['Active']); + expect(submitCall.status.value).toEqual(['Active']); + }); + + test('should only call onMetadataFilter when onFilterSubmit is not provided', async () => { + const onMetadataFilter = jest.fn(); + const template: MetadataTemplate = { + ...mockMetadataTemplate, + fields: [ + { + id: 'field1', + key: 'status', + displayName: 'Status', + type: 'enum', + options: [ + { id: 's1', key: 'Active' }, + { id: 's2', key: 'Inactive' }, + ], + }, + ], + }; + + renderComponent({ + metadataTemplate: template, + onMetadataFilter, + }); + + await userEvent().click(screen.getByRole('button', { name: /Status/ })); + await userEvent().click(within(screen.getByRole('menu')).getByRole('menuitemcheckbox', { name: 'Active' })); + + await waitFor(() => { + expect(onMetadataFilter).toHaveBeenCalledTimes(1); + }); + + const filterCall = onMetadataFilter.mock.calls[0][0]; + expect(filterCall.status.value).toEqual(['Active']); + }); + + test('should handle initial filter values transformation', () => { + const initialFilterValues = { + industry: { + fieldType: 'enum' as const, + value: ['tech'], + }, + price: { + fieldType: 'float' as const, + value: { range: { gt: 10, lt: 100 } }, + }, + name: { + fieldType: 'string' as const, + value: ['search term'], + }, + category: { + fieldType: 'multiSelect' as const, + value: ['category1', 'category2'], + }, + } as unknown as ExternalFilterValues; + + renderComponent({ + actionBarProps: { initialFilterValues }, + }); + + expect(screen.getByRole('button', { name: 'All Filters 3' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Industry/i })).toHaveTextContent(/\(1\)/); + expect(screen.getByRole('button', { name: /Category/i })).toHaveTextContent(/\(2\)/); + }); + + test('should handle empty metadata template fields', () => { + const emptyTemplate: MetadataTemplate = { + ...mockMetadataTemplate, + fields: [], + }; + + renderComponent({ metadataTemplate: emptyTemplate }); + + expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); + expect(screen.getByText('File 1.txt')).toBeInTheDocument(); + expect(screen.getByText('File 2.pdf')).toBeInTheDocument(); + }); + + test('should handle undefined metadata template', () => { + renderComponent({ metadataTemplate: undefined as unknown as MetadataTemplate }); + + expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); + expect(screen.getByText('File 1.txt')).toBeInTheDocument(); + expect(screen.getByText('File 2.pdf')).toBeInTheDocument(); + }); + + test('should handle empty collection items', () => { + const emptyCollection: Collection = { + id: '0', + items: [], + percentLoaded: 100, + }; + + renderComponent({ currentCollection: emptyCollection }); + + expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); + }); + + test('should handle undefined collection items', () => { + const collectionWithoutItems: Collection = { + id: '0', + percentLoaded: 100, + }; + + renderComponent({ currentCollection: collectionWithoutItems }); + + expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); + }); + + test('should memoize filterGroups when metadataTemplate changes', () => { + const { rerender } = renderComponent(); + + // Re-render with same template + rerender(); + + // Re-render with different template + const newTemplate: MetadataTemplate = { + ...mockMetadataTemplate, + id: 'template2', + displayName: 'New Template', + }; + rerender(); + + expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); + }); + + test('should handle fields with no options', () => { + const templateWithoutOptions: MetadataTemplate = { + ...mockMetadataTemplate, + fields: [ + { + id: 'field1', + key: 'name', + displayName: 'File Name', + type: 'string', + }, + { + id: 'field2', + key: 'industry', + displayName: 'Industry', + type: 'enum', + // No options defined + }, + ], + }; + + renderComponent({ metadataTemplate: templateWithoutOptions }); + + expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Name' })).toHaveLength(1); // Only the one added by component + expect(screen.getByRole('button', { name: 'File Name' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Industry' })).toBeInTheDocument(); + }); + + test('should handle multiple field types in filter submission', async () => { + const onFilterSubmit = jest.fn(); + const onMetadataFilter = jest.fn(); + const template: MetadataTemplate = { + ...mockMetadataTemplate, + fields: [ + { + id: 'field1', + key: 'status', + displayName: 'Status', + type: 'enum', + options: [ + { id: 's1', key: 'Active' }, + { id: 's2', key: 'Inactive' }, + ], + }, + { + id: 'field2', + key: 'price', + displayName: 'Price', + type: 'float', + }, + ], + }; + + renderComponent({ + metadataTemplate: template, + actionBarProps: { onFilterSubmit }, + onMetadataFilter, + }); + + // Test enum filter + await userEvent().click(screen.getByRole('button', { name: /Status/ })); + await userEvent().click(within(screen.getByRole('menu')).getByRole('menuitemcheckbox', { name: 'Active' })); + + await waitFor(() => { + expect(onMetadataFilter).toHaveBeenCalledTimes(1); + expect(onFilterSubmit).toHaveBeenCalledTimes(1); + }); + + const filterCall = onMetadataFilter.mock.calls[0][0]; + expect(filterCall.status.value).toEqual(['Active']); + expect(filterCall.status.fieldType).toBe('enum'); + }); + + describe('convertFilterValuesToExternal', () => { + test('should convert enum values to string arrays', () => { + const internalFilters = { + 'status-filter': { + fieldType: 'enum' as const, + options: [ + { key: 'active', id: 'active1' }, + { key: 'inactive', id: 'inactive1' }, + ], + value: { enum: ['active', 'inactive'] }, + }, + }; + + const result = convertFilterValuesToExternal(internalFilters); + + expect(result['status-filter'].value).toEqual(['active', 'inactive']); + expect(result['status-filter'].fieldType).toBe('enum'); + expect(result['status-filter'].options).toEqual([ + { key: 'active', id: 'active1' }, + { key: 'inactive', id: 'inactive1' }, + ]); + }); + + test('should keep range values unchanged', () => { + const internalFilters = { + 'price-filter': { + fieldType: 'float' as const, + value: { range: { gt: 10, lt: 100 }, advancedFilterOption: 'range' }, + }, + }; + + const result = convertFilterValuesToExternal(internalFilters); + + expect(result['price-filter'].value).toEqual({ range: { gt: 10, lt: 100 }, advancedFilterOption: 'range' }); + expect(result['price-filter'].fieldType).toBe('float'); + }); + + test('should keep float values unchanged', () => { + const internalFilters = { + 'rating-filter': { + fieldType: 'float' as const, + value: { range: { gt: 4.5, lt: 5.0 }, advancedFilterOption: 'range' }, + }, + }; + + const result = convertFilterValuesToExternal(internalFilters); + + expect(result['rating-filter'].value).toEqual({ + range: { gt: 4.5, lt: 5.0 }, + advancedFilterOption: 'range', + }); + expect(result['rating-filter'].fieldType).toBe('float'); + }); + + test('should handle mixed field types', () => { + const internalFilters = { + 'status-filter': { + fieldType: 'enum' as const, + options: [ + { key: 'active', id: 'active1' }, + { key: 'inactive', id: 'inactive1' }, + ], + value: { enum: ['active'] }, + }, + 'price-filter': { + fieldType: 'float' as const, + value: { range: { gt: 0, lt: 50 }, advancedFilterOption: 'range' }, + }, + 'category-filter': { + fieldType: 'multiSelect' as const, + options: [ + { key: 'tech', id: 'tech1' }, + { key: 'finance', id: 'finance1' }, + { key: 'healthcare', id: 'healthcare1' }, + ], + value: { enum: ['tech', 'finance'] }, + }, + }; + + const result = convertFilterValuesToExternal(internalFilters); + + expect(result['status-filter'].value).toEqual(['active']); + expect(result['price-filter'].value).toEqual({ range: { gt: 0, lt: 50 }, advancedFilterOption: 'range' }); + expect(result['category-filter'].value).toEqual(['tech', 'finance']); + }); + + test('should handle empty filter object', () => { + const result = convertFilterValuesToExternal({}); + expect(result).toEqual({}); + }); + + test('should handle enum values with empty array', () => { + const internalFilters = { + 'status-filter': { + fieldType: 'enum' as const, + options: [{ key: 'active', id: 'active1' }], + value: { enum: [] }, + }, + }; + + const result = convertFilterValuesToExternal(internalFilters); + + expect(result['status-filter'].value).toEqual([]); + expect(result['status-filter'].fieldType).toBe('enum'); + }); + + test('should handle multiSelect values', () => { + const internalFilters = { + 'category-filter': { + fieldType: 'multiSelect' as const, + options: [ + { key: 'tech', id: 'tech1' }, + { key: 'finance', id: 'finance1' }, + ], + value: { enum: ['tech', 'finance'] }, + }, + }; + + const result = convertFilterValuesToExternal(internalFilters); + + expect(result['category-filter'].value).toEqual(['tech', 'finance']); + expect(result['category-filter'].fieldType).toBe('multiSelect'); + }); }); }); diff --git a/src/elements/content-explorer/stories/MetadataView.stories.tsx b/src/elements/content-explorer/stories/MetadataView.stories.tsx index ba7bc705df..351dfa0820 100644 --- a/src/elements/content-explorer/stories/MetadataView.stories.tsx +++ b/src/elements/content-explorer/stories/MetadataView.stories.tsx @@ -23,37 +23,19 @@ const metadataQuery = { ancestor_folder_id: '0', fields: [ - `name`, `${metadataSourceFieldName}.industry`, `${metadataSourceFieldName}.last_contacted_at`, `${metadataSourceFieldName}.role`, + `${metadataSourceFieldName}.number`, ], }; -const fieldsToShow = [ - { key: `name` }, - { key: `${metadataSourceFieldName}.industry`, canEdit: true }, - { key: `${metadataSourceFieldName}.last_contacted_at`, canEdit: true }, - { key: `${metadataSourceFieldName}.role`, canEdit: true }, -]; - const columns = mockSchema.fields.map(field => { - if (field.key === 'name') { - return { - textValue: field.displayName, - id: 'name', - type: 'string', - allowsSorting: true, - minWidth: 250, - maxWidth: 250, - isRowHeader: true, - }; - } - if (field.type === 'date') { return { textValue: field.displayName, id: `${metadataSourceFieldName}.${field.key}`, + key: `${metadataSourceFieldName}.${field.key}`, type: field.type, allowsSorting: true, minWidth: 200, @@ -68,6 +50,7 @@ const columns = mockSchema.fields.map(field => { return { textValue: field.displayName, id: `${metadataSourceFieldName}.${field.key}`, + key: `${metadataSourceFieldName}.${field.key}`, type: field.type, allowsSorting: true, minWidth: 200, @@ -87,7 +70,6 @@ export const metadataView: Story = { }, }, metadataQuery, - fieldsToShow, defaultView, features: { contentExplorer: { 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 c854553462..846915bbb3 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -32,13 +32,11 @@ const metadataQuery = { fields: [ // Default to returning all fields in the metadata template schema, and name as a standalone (non-metadata) field ...mockSchema.fields.map(field => `${metadataFieldNamePrefix}.${field.key}`), - 'name', ], }; // Used for metadata view v1 const fieldsToShow = [ - { key: `${metadataFieldNamePrefix}.name`, canEdit: false, displayName: 'Alias' }, { key: `${metadataFieldNamePrefix}.industry`, canEdit: true }, { key: `${metadataFieldNamePrefix}.last_contacted_at`, canEdit: true }, { key: `${metadataFieldNamePrefix}.role`, canEdit: true }, @@ -46,15 +44,6 @@ const fieldsToShow = [ // Used for metadata view v2 const columns = [ - { - // Always include the name column - textValue: 'Name', - id: 'name', - type: 'string', - allowsSorting: true, - minWidth: 150, - maxWidth: 150, - }, ...mockSchema.fields.map(field => ({ textValue: field.displayName, id: `${metadataFieldNamePrefix}.${field.key}`, @@ -167,9 +156,9 @@ export const metadataViewV2WithCustomActions: Story = { const initialFilterActionBarProps = { initialFilterValues: { - 'industry-filter': { value: ['Legal'] }, + industry: { value: ['Legal'] }, 'mimetype-filter': { value: ['boxnoteType', 'documentType', 'threedType'] }, - 'role-filter': { value: ['Developer', 'Business Owner', 'Marketing'] }, + role: { value: ['Developer', 'Business Owner', 'Marketing'] }, }, }; diff --git a/yarn.lock b/yarn.lock index 81b9ec5bca..befd7d8610 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1522,10 +1522,10 @@ resolved "https://registry.yarnpkg.com/@box/metadata-filter/-/metadata-filter-1.19.3.tgz#87364bea4cbb1417866e65639f3b1e137a6d9b6a" integrity sha512-5cSY8yLW7S1zsiqBHAuKkHjcyHFBuBUBHGTnYigV0eKyLH4Dm9ozjon23P3Z9HXVB5IMHwTM3I9TRDFAZuP7vw== -"@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/metadata-view@^0.48.1": + version "0.48.1" + resolved "https://registry.yarnpkg.com/@box/metadata-view/-/metadata-view-0.48.1.tgz#58cab5153cf343726aa9718debccdca88e8fb10f" + integrity sha512-+OeSLT5AEqgSDz1p61mV/VVPb/qGAmlC/+GOyqSZ/aSu2D8owUUS0BO/rPVIF1DA56NrPoeGfQA8GgxdKzNisA== "@box/react-virtualized@^9.22.3-rc-box.10": version "9.22.3-rc-box.10" @@ -18226,7 +18226,7 @@ string-replace-loader@^3.1.0: loader-utils "^2.0.0" schema-utils "^3.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -18261,15 +18261,6 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -18376,7 +18367,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -18404,13 +18395,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -19963,7 +19947,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -19998,15 +19982,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"