From eec67358c6fb5e73baf3458d04d2f2b0872c08af Mon Sep 17 00:00:00 2001 From: Greg Wong Date: Wed, 9 Jul 2025 18:58:29 -0400 Subject: [PATCH] feat(metadata-view): Add MetadataView V2 --- package.json | 14 +- scripts/i18n.config.js | 3 + scripts/jest/jest.config.js | 2 +- src/elements/common/__mocks__/mockMetadata.ts | 112 ++++- src/elements/content-explorer/Content.tsx | 20 +- .../content-explorer/ContentExplorer.tsx | 50 +- .../MetadataQueryAPIHelper.ts | 232 +++++++++ .../content-explorer/MetadataView.tsx | 7 - .../MetadataViewContainer.tsx | 52 ++ .../__tests__/Content.test.tsx | 20 +- .../__tests__/ContentExplorer.test.tsx | 9 +- .../__tests__/MetadataQueryAPIHelper.test.ts | 454 ++++++++++++++++++ .../__tests__/MetadataViewContainer.test.tsx | 83 ++++ .../stories/MetadataView.stories.tsx | 128 +++++ .../tests/MetadataView-visual.stories.tsx | 22 +- yarn.lock | 153 ++++-- 16 files changed, 1283 insertions(+), 78 deletions(-) create mode 100644 src/elements/content-explorer/MetadataQueryAPIHelper.ts delete mode 100644 src/elements/content-explorer/MetadataView.tsx create mode 100644 src/elements/content-explorer/MetadataViewContainer.tsx create mode 100644 src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts create mode 100644 src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx create mode 100644 src/elements/content-explorer/stories/MetadataView.stories.tsx diff --git a/package.json b/package.json index 19277c8814..02c45a2825 100644 --- a/package.json +++ b/package.json @@ -128,12 +128,15 @@ "@box/blueprint-web-assets": "4.61.5", "@box/box-ai-agent-selector": "^0.48.5", "@box/box-ai-content-answers": "^0.124.1", + "@box/box-item-type-selector": "^0.61.12", "@box/cldr-data": "^34.2.0", "@box/combobox-with-api": "^0.34.9", "@box/frontend": "^11.0.1", "@box/item-icon": "^0.17.0", "@box/languages": "^1.0.0", - "@box/metadata-editor": "^0.122.0", + "@box/metadata-editor": "^0.122.12", + "@box/metadata-filter": "^1.16.12", + "@box/metadata-view": "^0.29.4", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@cfaester/enzyme-adapter-react-18": "^0.8.0", @@ -150,6 +153,7 @@ "@storybook/addon-styling-webpack": "^2.0.0", "@storybook/addon-webpack5-compiler-babel": "^3.0.6", "@storybook/react-webpack5": "^9.0.14", + "@tanstack/react-virtual": "^3.13.12", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", @@ -244,6 +248,7 @@ "query-string": "5.1.1", "react": "^18.3.1", "react-animate-height": "^3.2.3", + "react-aria-components": "^1.10.1", "react-beautiful-dnd": "^13.1.1", "react-docgen-typescript": "^1.16.1", "react-docgen-typescript-loader": "^3.6.0", @@ -292,13 +297,17 @@ "@box/blueprint-web-assets": "4.61.5", "@box/box-ai-agent-selector": "^0.48.5", "@box/box-ai-content-answers": "^0.124.1", + "@box/box-item-type-selector": "^0.61.12", "@box/cldr-data": ">=34.2.0", "@box/combobox-with-api": "^0.34.9", "@box/item-icon": "^0.17.0", - "@box/metadata-editor": "^0.122.0", + "@box/metadata-editor": "^0.122.12", + "@box/metadata-filter": "^1.16.12", + "@box/metadata-view": "^0.29.4", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@hapi/address": "^2.1.4", + "@tanstack/react-virtual": "^3.13.12", "axios": "^0.30.0", "classnames": "^2.2.5", "color": "^3.1.2", @@ -319,6 +328,7 @@ "query-string": "5.1.1", "react": "^17.0.1 || ^18.0.0", "react-animate-height": "^3.2.3", + "react-aria-components": "^1.10.1", "react-beautiful-dnd": "^13.1.1", "react-dom": "^17.0.1 || ^18.0.0", "react-draggable": "^4.4.6", diff --git a/scripts/i18n.config.js b/scripts/i18n.config.js index 0f8370128b..8bfc856357 100644 --- a/scripts/i18n.config.js +++ b/scripts/i18n.config.js @@ -3,7 +3,10 @@ module.exports = { translationDependencies: [ '@box/box-ai-agent-selector', '@box/box-ai-content-answers', + '@box/box-item-type-selector', '@box/item-icon', '@box/metadata-editor', + '@box/metadata-filter', + '@box/metadata-view', ], }; diff --git a/scripts/jest/jest.config.js b/scripts/jest/jest.config.js index e547efda1f..c558824b13 100644 --- a/scripts/jest/jest.config.js +++ b/scripts/jest/jest.config.js @@ -26,6 +26,6 @@ module.exports = { testMatch: ['**/__tests__/**/*.test.+(js|jsx|ts|tsx)'], testPathIgnorePatterns: ['stories.test.js$', 'stories.test.tsx$', 'stories.test.d.ts'], transformIgnorePatterns: [ - 'node_modules/(?!(@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/types)/)', + 'node_modules/(?!(@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/types|@box/box-item-type-selector)/)', ], }; diff --git a/src/elements/common/__mocks__/mockMetadata.ts b/src/elements/common/__mocks__/mockMetadata.ts index 97cd652113..8391b488d4 100644 --- a/src/elements/common/__mocks__/mockMetadata.ts +++ b/src/elements/common/__mocks__/mockMetadata.ts @@ -1,8 +1,7 @@ const mockMetadata = { entries: [ { - name: 'File1', - etag: '2', + extension: 'pdf', metadata: { enterprise_0: { templateName: { @@ -13,17 +12,18 @@ const mockMetadata = { name: 'something', industry: 'Technology', last_contacted_at: '2023-11-16T00:00:00.000Z', - $version: 6, + $version: 9, }, }, }, - id: '1188899160835', - modified_at: '2023-04-12T10:06:04-07:00', + name: 'Child 2 of metadata folder.pdf', + created_at: '2023-04-12T10:06:04-07:00', + etag: '3', + id: '1188890835', type: 'file', }, { - name: 'File2', - etag: '1', + extension: 'pdf', metadata: { enterprise_0: { templateName: { @@ -32,31 +32,113 @@ const mockMetadata = { $template: 'templateName', $parent: 'file_1318276254035', name: '1', - industry: 'Healthcare', + industry: 'Technology', last_contacted_at: '2023-11-01T00:00:00.000Z', + $version: 3, + }, + }, + }, + name: 'Child 1 of metadata folder.pdf', + created_at: '2023-09-26T14:04:52-07:00', + etag: '2', + id: '13182754035', + type: 'file', + }, + { + extension: 'pdf', + metadata: { + enterprise_0: { + templateName: { + $scope: 'enterprise_0', + role: ['Developer', 'Business Owner'], + $template: 'templateName', + $parent: 'file_1812488409191', + industry: 'Legal', + last_contacted_at: '2025-03-05T00:00:00.000Z', $version: 1, }, }, }, - id: '1318276254035', - modified_at: '2023-09-26T14:04:52-07:00', + name: 'Child of Folder 1.pdf', + created_at: '2025-03-24T11:19:06-07:00', + etag: '2', + id: '18124889191', + type: 'file', + }, + { + extension: 'pdf', + metadata: { + enterprise_0: { + templateName: { + $scope: 'enterprise_0', + role: ['Legal', 'Marketing'], + $template: 'templateName', + $parent: 'file_1812500610112', + industry: 'Legal', + last_contacted_at: '2025-03-11T00:00:00.000Z', + $version: 3, + }, + }, + }, + name: 'Child 1 of metadata folder 2.pdf', + created_at: '2025-03-24T11:38:55-07:00', + etag: '1', + id: '18125010112', + type: 'file', + }, + { + extension: 'pdf', + metadata: { + enterprise_0: { + templateName: { + $scope: 'enterprise_0', + $template: 'templateName', + $parent: 'file_1812508470016', + name: 'in folder 3 that doesnt have metadata', + $version: 0, + }, + }, + }, + name: 'Child 1 of folder 3.pdf', + created_at: '2025-03-24T11:50:52-07:00', + etag: '2', + id: '18125470016', type: 'file', }, { - name: 'File3', - etag: '0', + name: 'Folder 1 with metadata', + created_at: '2025-03-24T11:18:44-07:00', + etag: '2', metadata: { enterprise_0: { templateName: { $scope: 'enterprise_0', $template: 'templateName', - $parent: 'folder_218662304788', + $parent: 'folder_313222720346', + industry: 'Technology', + $version: 1, + }, + }, + }, + id: '3132220346', + type: 'folder', + }, + { + name: 'Folder 2 with metadata', + created_at: '2025-03-24T11:37:27-07:00', + etag: '1', + metadata: { + enterprise_0: { + templateName: { + $scope: 'enterprise_0', + $template: 'templateName', + $parent: 'folder_313225735088', + industry: 'Healthcare', $version: 0, }, }, }, - id: '218662304788', - modified_at: '2024-06-13T15:53:23-07:00', + id: '3135735088', type: 'folder', }, ], diff --git a/src/elements/content-explorer/Content.tsx b/src/elements/content-explorer/Content.tsx index 8c43b43fa7..5bb42fe8ef 100644 --- a/src/elements/content-explorer/Content.tsx +++ b/src/elements/content-explorer/Content.tsx @@ -4,14 +4,14 @@ 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 MetadataView from './MetadataView'; +import MetadataViewContainer, { 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'; import type { ItemAction, ItemEventHandlers, ItemEventPermissions } from '../common/item'; import type { FieldsToShow } from '../../common/types/metadataQueries'; import type { BoxItem, Collection, View } from '../../common/types/core'; -import type { MetadataFieldValue } from '../../common/types/metadata'; +import type { MetadataFieldValue, MetadataTemplate } from '../../common/types/metadata'; import './Content.scss'; /** @@ -36,6 +36,8 @@ export interface ContentProps extends Required, Required; onMetadataUpdate: ( item: BoxItem, field: string, @@ -53,6 +55,8 @@ const Content = ({ features, fieldsToShow = [], gridColumnCount, + metadataTemplate, + metadataViewProps, onMetadataUpdate, onSortChange, view, @@ -70,7 +74,7 @@ const Content = ({
{view === VIEW_ERROR || view === VIEW_SELECTED ? null : } - {isViewEmpty && } + {!isMetadataViewV2Feature && isViewEmpty && } {!isMetadataViewV2Feature && !isViewEmpty && isMetadataBasedView && ( )} - {isMetadataViewV2Feature && !isViewEmpty && isMetadataBasedView && } + {isMetadataViewV2Feature && isMetadataBasedView && ( + + )} {!isViewEmpty && isListView && ( void; messages?: StringMap; metadataQuery?: MetadataQuery; + metadataViewProps?: Omit; onCreate?: (item: BoxItem) => void; onDelete?: (item: BoxItem) => void; onDownload?: (item: BoxItem) => void; @@ -163,10 +171,11 @@ type State = { isShareModalOpen: boolean; isUploadModalOpen: boolean; markers: Array; + metadataTemplate: MetadataTemplate; rootName: string; searchQuery: string; selected?: BoxItem; - sortBy: SortBy; + sortBy: SortBy | string; sortDirection: SortDirection; view: View; }; @@ -227,6 +236,7 @@ class ContentExplorer extends Component { contentSidebarProps: {}, }, contentUploaderProps: {}, + metadataViewProps: {}, }; /** @@ -285,6 +295,7 @@ class ContentExplorer extends Component { isShareModalOpen: false, isUploadModalOpen: false, markers: [], + metadataTemplate: {}, rootName: '', searchQuery: '', sortBy, @@ -368,7 +379,10 @@ class ContentExplorer extends Component { * @param {Object} metadataQueryCollection - Metadata query response collection * @return {void} */ - showMetadataQueryResultsSuccessCallback = (metadataQueryCollection: Collection): void => { + showMetadataQueryResultsSuccessCallback = ( + metadataQueryCollection: Collection, + metadataTemplate: MetadataTemplate, + ): void => { const { nextMarker } = metadataQueryCollection; const { currentCollection, currentPageNumber, markers }: State = this.state; const cloneMarkers = [...markers]; @@ -382,6 +396,7 @@ class ContentExplorer extends Component { percentLoaded: 100, }, markers: cloneMarkers, + metadataTemplate, }); }; @@ -392,8 +407,8 @@ class ContentExplorer extends Component { * @return {void} */ showMetadataQueryResults() { - const { metadataQuery = {} }: ContentExplorerProps = this.props; - const { currentPageNumber, markers }: State = this.state; + const { features, metadataQuery = {} }: ContentExplorerProps = this.props; + const { currentPageNumber, markers, sortBy, sortDirection }: State = this.state; const metadataQueryClone = cloneDeep(metadataQuery); if (currentPageNumber === 0) { @@ -410,13 +425,26 @@ class ContentExplorer extends Component { // Set limit to the query for pagination support metadataQueryClone.limit = DEFAULT_PAGE_SIZE; } + + metadataQueryClone.order_by = [ + { + field_key: sortBy, + direction: sortDirection, + }, + ]; // Reset search state, the view and show busy indicator this.setState({ searchQuery: '', currentCollection: this.currentUnloadedCollection(), view: VIEW_METADATA, }); - this.metadataQueryAPIHelper = new MetadataQueryAPIHelper(this.api); + + if (isFeatureEnabled(features, 'contentExplorer.metadataViewV2')) { + this.metadataQueryAPIHelper = new MetadataQueryAPIHelperV2(this.api); + } else { + this.metadataQueryAPIHelper = new MetadataQueryAPIHelper(this.api); + } + this.metadataQueryAPIHelper.fetchMetadataQueryResults( metadataQueryClone, this.showMetadataQueryResultsSuccessCallback, @@ -831,8 +859,10 @@ class ContentExplorer extends Component { sort = (sortBy: SortBy, sortDirection: SortDirection) => { const { currentCollection: { id }, + view, }: State = this.state; - if (id) { + + if (id || view === VIEW_METADATA) { this.setState({ sortBy, sortDirection }, this.refreshCollection); } }; @@ -1602,6 +1632,7 @@ class ContentExplorer extends Component { measureRef, messages, fieldsToShow, + metadataViewProps, onDownload, onPreview, onUpload, @@ -1632,6 +1663,7 @@ class ContentExplorer extends Component { isShareModalOpen, isUploadModalOpen, markers, + metadataTemplate, rootName, selected, view, @@ -1697,6 +1729,8 @@ class ContentExplorer extends Component { isTouch={isTouch} itemActions={itemActions} fieldsToShow={fieldsToShow} + metadataTemplate={metadataTemplate} + metadataViewProps={metadataViewProps} onItemClick={this.onItemClick} onItemDelete={this.delete} onItemDownload={this.download} diff --git a/src/elements/content-explorer/MetadataQueryAPIHelper.ts b/src/elements/content-explorer/MetadataQueryAPIHelper.ts new file mode 100644 index 0000000000..ba091a856e --- /dev/null +++ b/src/elements/content-explorer/MetadataQueryAPIHelper.ts @@ -0,0 +1,232 @@ +import cloneDeep from 'lodash/cloneDeep'; +import find from 'lodash/find'; +import getProp from 'lodash/get'; +import includes from 'lodash/includes'; +import isArray from 'lodash/isArray'; +import isNil from 'lodash/isNil'; +import API from '../../api'; + +import { + JSON_PATCH_OP_ADD, + JSON_PATCH_OP_REMOVE, + JSON_PATCH_OP_REPLACE, + JSON_PATCH_OP_TEST, + METADATA_FIELD_TYPE_ENUM, + METADATA_FIELD_TYPE_MULTISELECT, +} from '../../common/constants'; +import { FIELD_NAME, FIELD_METADATA, FIELD_EXTENSION } from '../../constants'; + +import type { MetadataQuery as MetadataQueryType, MetadataQueryResponseData } from '../../common/types/metadataQueries'; +import type { + MetadataTemplateSchemaResponse, + MetadataTemplate, + MetadataFieldValue, + MetadataType, + MetadataQueryInstanceTypeField, +} from '../../common/types/metadata'; +import type { ElementsXhrError, JSONPatchOperations } from '../../common/types/api'; +import type { Collection, BoxItem } from '../../common/types/core'; + +type SuccessCallback = (metadataQueryCollection: Collection, metadataTemplate: MetadataTemplate) => void; +type ErrorCallback = (e: ElementsXhrError) => void; + +const SELECT_TYPES: Array = [ + METADATA_FIELD_TYPE_ENUM, + METADATA_FIELD_TYPE_MULTISELECT, +]; + +export default class MetadataQueryAPIHelper { + api: API; + + metadataQueryResponseData: MetadataQueryResponseData; + + metadataTemplate: MetadataTemplate; + + templateKey: string; + + templateScope: string; + + metadataQuery: MetadataQueryType; + + constructor(api: API) { + this.api = api; + } + + createJSONPatchOperations = ( + field: string, + oldValue: MetadataFieldValue | null, + newValue: MetadataFieldValue | null, + ): JSONPatchOperations => { + let operation = JSON_PATCH_OP_REPLACE; + + if (isNil(oldValue) && newValue) { + operation = JSON_PATCH_OP_ADD; + } + + if (oldValue && isNil(newValue)) { + operation = JSON_PATCH_OP_REMOVE; + } + + const testOp = { + op: JSON_PATCH_OP_TEST, + path: `/${field}`, + value: oldValue, + }; + const patchOp = { + op: operation, + path: `/${field}`, + value: newValue, + }; + + if (operation === JSON_PATCH_OP_REMOVE) { + delete patchOp.value; + } + + return operation === JSON_PATCH_OP_ADD ? [patchOp] : [testOp, patchOp]; + }; + + getMetadataQueryFields = (): string[] => { + /* + Example metadata query: + const query = { + from: 'enterprise_12345.myAwesomeTemplateKey', + fields: [ + 'name', // base representation field for an item (name, size, etag etc.) + 'metadata.enterprise_12345.myAwesomeTemplateKey.field_1', // metadata instance field + 'metadata.enterprise_12345.myAwesomeTemplateKey.field_2', // metadata instance field + 'metadata.enterprise_12345.myAwesomeTemplateKey.field_3' // metadata instance field + ], + ancestor_folder_id: 0, + }; + + This function will return ['field_1', 'field_2', 'field_3'] + */ + const { fields = [], from } = this.metadataQuery; + return fields.filter(field => field.includes(from)).map(field => field.split('.').pop()); + }; + + flattenMetadata = (metadata?: MetadataType): MetadataType => { + const templateFields = getProp(this.metadataTemplate, 'fields', []); + const instance = getProp(metadata, `${this.templateScope}.${this.templateKey}`); + + if (!instance) { + return {}; + } + + const queryFields = this.getMetadataQueryFields(); + + const fields = queryFields.map((queryField: string) => { + const templateField = find(templateFields, ['key', queryField]); + const type = getProp(templateField, 'type'); // get data type + const displayName = getProp(templateField, 'displayName', queryField); // get displayName, defaults to key + + const field: MetadataQueryInstanceTypeField = { + key: `${FIELD_METADATA}.${this.templateScope}.${this.templateKey}.${queryField}`, + value: instance[queryField], + type, + displayName, + }; + + if (includes(SELECT_TYPES, type)) { + // get "options" for enums or multiselects + field.options = getProp(templateField, 'options'); + } + + return field; + }); + + return { + enterprise: { + fields, + id: instance.$id, + }, + }; + }; + + getDataWithTypes = (templateSchemaResponse?: MetadataTemplateSchemaResponse): Collection => { + this.metadataTemplate = getProp(templateSchemaResponse, 'data'); + + const { entries: items, next_marker: nextMarker }: MetadataQueryResponseData = this.metadataQueryResponseData; + + return { + items, + nextMarker, + }; + }; + + getTemplateSchemaInfo = (data: MetadataQueryResponseData): Promise => { + const { entries } = data; + this.metadataQueryResponseData = data; + if (!entries || entries.length === 0) { + // Don't make metadata API call to get template info + return Promise.resolve(); + } + + const metadata = getProp(entries, '[0].metadata'); + this.templateScope = Object.keys(metadata)[0]; + const instance = metadata[this.templateScope]; + this.templateKey = Object.keys(instance)[0]; + + return this.api.getMetadataAPI(true).getSchemaByTemplateKey(this.templateKey); + }; + + queryMetadata = (): Promise => { + return new Promise((resolve, reject) => { + this.api.getMetadataQueryAPI().queryMetadata(this.metadataQuery, resolve, reject, { forceFetch: true }); + }); + }; + + fetchMetadataQueryResults = ( + metadataQuery: MetadataQueryType, + successCallback: SuccessCallback, + errorCallback: ErrorCallback, + ): Promise => { + this.metadataQuery = this.verifyQueryFields(metadataQuery); + return this.queryMetadata() + .then(this.getTemplateSchemaInfo) + .then(this.getDataWithTypes) + .then((collection: Collection) => { + return successCallback(collection, this.metadataTemplate); + }) + .catch(errorCallback); + }; + + updateMetadata = ( + file: BoxItem, + field: string, + oldValue: MetadataFieldValue | null, + newValue: MetadataFieldValue | null, + successCallback: () => void, + errorCallback: ErrorCallback, + ): Promise => { + const operations = this.createJSONPatchOperations(field, oldValue, newValue); + return this.api + .getMetadataAPI(true) + .updateMetadata(file, this.metadataTemplate, operations, successCallback, errorCallback); + }; + + /** + * 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 + * @return {MetadataQueryType} updated metadata query object with required fields + */ + verifyQueryFields = (metadataQuery: MetadataQueryType): MetadataQueryType => { + const clonedQuery = cloneDeep(metadataQuery); + const clonedFields = isArray(clonedQuery.fields) ? clonedQuery.fields : []; + + // 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_EXTENSION)) { + clonedFields.push(FIELD_EXTENSION); + } + + clonedQuery.fields = clonedFields; + + return clonedQuery; + }; +} diff --git a/src/elements/content-explorer/MetadataView.tsx b/src/elements/content-explorer/MetadataView.tsx deleted file mode 100644 index 3e57fe5b61..0000000000 --- a/src/elements/content-explorer/MetadataView.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import * as React from 'react'; - -const MetadataView = () => { - return <>new Metadata; -}; - -export default MetadataView; diff --git a/src/elements/content-explorer/MetadataViewContainer.tsx b/src/elements/content-explorer/MetadataViewContainer.tsx new file mode 100644 index 0000000000..f142563f54 --- /dev/null +++ b/src/elements/content-explorer/MetadataViewContainer.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { MetadataView, type MetadataViewProps } from '@box/metadata-view'; +import type { MetadataTemplate } from '../../common/types/metadata'; +import type { Collection } from '../../common/types/core'; + +export interface MetadataViewContainerProps extends Omit { + currentCollection: Collection; + metadataTemplate: MetadataTemplate; +} + +const MetadataViewContainer = ({ + actionBarProps, + columns, + currentCollection, + metadataTemplate, + ...rest +}: MetadataViewContainerProps) => { + const { items = [] } = currentCollection; + + const filterGroups = React.useMemo( + () => [ + { + toggleable: true, + filters: + metadataTemplate?.fields?.map(field => { + return { + id: `${field.key}-filter`, + name: field.displayName, + fieldType: field.type, + options: field.options?.map(({ key }) => key) || [], + shouldRenderChip: true, + }; + }) || [], + }, + ], + [metadataTemplate], + ); + + return ( + + ); +}; + +export default MetadataViewContainer; diff --git a/src/elements/content-explorer/__tests__/Content.test.tsx b/src/elements/content-explorer/__tests__/Content.test.tsx index 40303e801c..920456e438 100644 --- a/src/elements/content-explorer/__tests__/Content.test.tsx +++ b/src/elements/content-explorer/__tests__/Content.test.tsx @@ -37,6 +37,11 @@ const mockProps: ContentProps = { viewMode: VIEW_MODE_LIST, }; +jest.mock('../MetadataViewContainer', () => ({ + __esModule: true, + default: () =>
MetadataViewContainer
, +})); + describe('Content Component', () => { const renderComponent = (props: Partial = {}) => { return render(); @@ -88,12 +93,18 @@ describe('Content Component', () => { const features = { contentExplorer: { metadataViewV2: true }, }; + const collection = { + percentLoaded: 100, + boxItem: {}, + id: '0', + items: [{ id: 1 }], + name: 'name', + }; test('does not render MetadataBasedItemList when contentExplorer.metadataViewV2 is enabled', () => { - const collection = { boxItem: {}, id: '0', items: [{ id: 1 }], name: 'name' }; renderComponent({ - features, currentCollection: collection, + features, fieldsToShow: ['id'], view: VIEW_METADATA, }); @@ -102,15 +113,14 @@ describe('Content Component', () => { }); test('renders new metadata view when contentExplorer.metadataViewV2 is enabled', () => { - const collection = { boxItem: {}, id: '0', items: [{ id: 1 }], name: 'name' }; renderComponent({ - features, currentCollection: collection, + features, fieldsToShow: ['id'], view: VIEW_METADATA, }); - expect(screen.getByText('new Metadata')).toBeInTheDocument(); + expect(screen.getByText('MetadataViewContainer')).toBeInTheDocument(); }); }); }); diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index 046fbd6da6..8b77e83f69 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -29,7 +29,9 @@ jest.mock('../../../utils/Xhr', () => { post: jest.fn(({ url }) => { switch (url) { case 'https://api.box.com/2.0/metadata_queries/execute_read': - return Promise.resolve({ data: mockMetadata }); + return Promise.resolve({ + data: { limit: mockMetadata.limit, entries: [mockMetadata.entries[0]] }, + }); default: return Promise.reject(new Error('Not Found')); } @@ -408,12 +410,9 @@ describe('elements/content-explorer/ContentExplorer', () => { expect(screen.getByText('Name')).toBeInTheDocument(); expect(screen.getByText('Industry Alias')).toBeInTheDocument(); expect(screen.getByText('Last Contacted At')).toBeInTheDocument(); - expect(screen.getByText('File1')).toBeInTheDocument(); - expect(screen.getByText('File2')).toBeInTheDocument(); + expect(screen.getByText('Child 2 of metadata folder.pdf')).toBeInTheDocument(); expect(screen.getByText('Technology')).toBeInTheDocument(); expect(screen.getByText('November 16, 2023')).toBeInTheDocument(); - expect(screen.getByText('Healthcare')).toBeInTheDocument(); - expect(screen.getByText('November 1, 2023')).toBeInTheDocument(); }); describe('Metadata View V2', () => { test('should render metadata view button', async () => { diff --git a/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts b/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts new file mode 100644 index 0000000000..b996a6265d --- /dev/null +++ b/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts @@ -0,0 +1,454 @@ +import includes from 'lodash/includes'; +import isArray from 'lodash/isArray'; + +import MetadataQueryAPIHelper from '../MetadataQueryAPIHelper'; +import { + JSON_PATCH_OP_ADD, + JSON_PATCH_OP_REMOVE, + JSON_PATCH_OP_REPLACE, + JSON_PATCH_OP_TEST, +} from '../../../common/constants'; +import { FIELD_METADATA, FIELD_NAME, FIELD_EXTENSION } from '../../../constants'; + +describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { + let metadataQueryAPIHelper; + const templateScope = 'enterprise_12345'; + const templateKey = 'awesomeTemplate'; + const metadataInstance = { instance: 'instance' }; + const metadataInstanceId1 = 'metadataInstanceId1'; + const metadataInstanceId2 = 'metadataInstanceId2'; + const options = [ + { + id: '3887b73d-0087-43bb-947e-0dff1543bdfb', + key: 'yes', + }, + { + id: '3393eed2-f254-4fd0-a7ff-cd9a5f75e222', + key: 'no', + }, + ]; + const template = { + id: 'cdb8c36d-4470-41df-90ba', + type: 'metadata_template', + templateKey, + scope: templateScope, + displayName: 'Test Template', + hidden: false, + fields: [ + { + id: '854045ee-a219-47ef-93ec-6e3b3417b68f', + type: 'string', + key: 'type', + displayName: 'type', + hidden: false, + description: 'type', + }, + { + id: '04af7602-7cad-4d60-b843-acc14b0ef587', + type: 'float', + key: 'year', + displayName: 'year', + hidden: false, + description: 'year', + }, + { + id: '9e5849a1-02f4-4a9a-b626-91fe46a89f2a', + type: 'enum', + key: 'approved', + displayName: 'approved', + hidden: false, + description: 'approved yes/no', + options, + }, + ], + }; + const templateSchemaResponse = { + data: template, + }; + const nextMarker = 'marker1234567890'; + const metadataQueryResponse = { + entries: [ + { + type: 'file', + id: '1234', + name: 'filename1.pdf', + size: 10000, + metadata: { + [templateScope]: { + [templateKey]: { + $id: metadataInstanceId1, + $parent: 'file_998877', + $type: 'awesomeTemplateKey-asdlk-1234-asd1', + $typeScope: 'enterprise_2222', + $typeVersion: 0, + $version: 0, + type: 'bill', // metadata template field + year: 2017, // metadata template field + approved: 'yes', // metadata template field + }, + }, + }, + }, + { + type: 'file', + id: '9876', + name: 'filename2.mp4', + size: 50000, + metadata: { + [templateScope]: { + [templateKey]: { + $id: metadataInstanceId2, + $parent: 'file_998877', + $type: 'awesomeTemplateKey-asdlk-1234-asd1', + $typeScope: 'enterprise_2222', + $typeVersion: 0, + $version: 0, + type: 'receipt', // metadata template field + year: 2018, // metadata template field + approved: 'no', // metadata template field + }, + }, + }, + }, + ], + next_marker: nextMarker, + }; + const flattenedMetadataEntries = [ + { + enterprise: { + fields: [ + { + displayName: 'type', + key: `${FIELD_METADATA}.${templateScope}.${templateKey}.type`, + value: 'bill', + type: 'string', + }, + { + displayName: 'year', + key: `${FIELD_METADATA}.${templateScope}.${templateKey}.year`, + value: 2017, + type: 'float', + }, + { + displayName: 'approved', + key: `${FIELD_METADATA}.${templateScope}.${templateKey}.approved`, + value: 'yes', + type: 'enum', + options, + }, + ], + id: metadataInstanceId1, + }, + }, + { + enterprise: { + fields: [ + { + displayName: 'type', + key: `${FIELD_METADATA}.${templateScope}.${templateKey}.type`, + value: 'receipt', + type: 'string', + }, + { + displayName: 'year', + key: `${FIELD_METADATA}.${templateScope}.${templateKey}.year`, + value: 2018, + type: 'float', + }, + { + displayName: 'approved', + key: `${FIELD_METADATA}.${templateScope}.${templateKey}.approved`, + value: 'no', + type: 'enum', + options, + }, + ], + id: metadataInstanceId2, + }, + }, + ]; + const dataWithTypes = { + items: metadataQueryResponse.entries, + nextMarker: metadataQueryResponse.next_marker, + }; + const getSchemaByTemplateKeyFunc = jest.fn().mockReturnValueOnce(Promise.resolve(templateSchemaResponse)); + const queryMetadataFunc = jest.fn().mockReturnValueOnce(Promise.resolve(metadataQueryResponse)); + const updateMetadataFunc = jest.fn().mockReturnValueOnce(Promise.resolve(metadataInstance)); + const api = { + getMetadataAPI: () => { + return { + getSchemaByTemplateKey: getSchemaByTemplateKeyFunc, + updateMetadata: updateMetadataFunc, + }; + }, + getMetadataQueryAPI: () => { + return { + queryMetadata: queryMetadataFunc, + }; + }, + }; + const mdQuery = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + query: 'query', + query_params: {}, + fields: [ + FIELD_NAME, + 'metadata.enterprise_1234.templateKey.type', + 'metadata.enterprise_1234.templateKey.year', + 'metadata.enterprise_1234.templateKey.approved', + ], + }; + + beforeEach(() => { + metadataQueryAPIHelper = new MetadataQueryAPIHelper(api); + metadataQueryAPIHelper.templateKey = templateKey; + metadataQueryAPIHelper.templateScope = templateScope; + metadataQueryAPIHelper.metadataTemplate = template; + metadataQueryAPIHelper.metadataQuery = mdQuery; + }); + + describe('flattenMetadata()', () => { + const { entries } = metadataQueryResponse; + test.each` + entryIndex | metadataResponseEntry | flattenedMetadataEntry + ${0} | ${entries[0].metadata} | ${flattenedMetadataEntries[0]} + ${1} | ${entries[1].metadata} | ${flattenedMetadataEntries[1]} + `( + 'should return correct flattened metadata for entry $entryIndex', + ({ metadataResponseEntry, flattenedMetadataEntry }) => { + const result = metadataQueryAPIHelper.flattenMetadata(metadataResponseEntry); + expect(result).toEqual(flattenedMetadataEntry); + }, + ); + + test('should return empty object when instance is not found', () => { + expect(metadataQueryAPIHelper.flattenMetadata(undefined)).toEqual({}); + }); + }); + + describe('getDataWithTypes()', () => { + test('should return data with types and set template object on the instance', () => { + metadataQueryAPIHelper.metadataQueryResponseData = metadataQueryResponse; + const result = metadataQueryAPIHelper.getDataWithTypes(templateSchemaResponse); + expect(result).toEqual(dataWithTypes); + expect(metadataQueryAPIHelper.metadataTemplate).toEqual(template); + }); + }); + + describe('getTemplateSchemaInfo()', () => { + test('should set instance properties and make xhr call to get template info when response has valid entries', async () => { + const result = await metadataQueryAPIHelper.getTemplateSchemaInfo(metadataQueryResponse); + expect(getSchemaByTemplateKeyFunc).toHaveBeenCalledWith(templateKey); + expect(result).toEqual(templateSchemaResponse); + expect(metadataQueryAPIHelper.metadataQueryResponseData).toEqual(metadataQueryResponse); + expect(metadataQueryAPIHelper.templateScope).toEqual(templateScope); + expect(metadataQueryAPIHelper.templateKey).toEqual(templateKey); + }); + + test('should not make xhr call to get metadata template info when response has zero/invalid entries', async () => { + const emptyEntriesResponse = { entries: [], next_marker: nextMarker }; + const result = await metadataQueryAPIHelper.getTemplateSchemaInfo(emptyEntriesResponse); + expect(getSchemaByTemplateKeyFunc).not.toHaveBeenCalled(); + expect(result).toBe(undefined); + expect(metadataQueryAPIHelper.metadataQueryResponseData).toEqual(emptyEntriesResponse); + }); + }); + + describe('queryMetadata()', () => { + test('should return a promise that resolves with metadata query result', async () => { + const result = metadataQueryAPIHelper.queryMetadata(); + expect(result).toBeInstanceOf(Promise); + expect(queryMetadataFunc).toBeCalledWith( + mdQuery, + expect.any(Function), // resolve + expect.any(Function), // reject + { forceFetch: true }, + ); + }); + }); + + describe('fetchMetadataQueryResults()', () => { + test('should fetch metadata query results, template info, and call successCallback with data with data types', async () => { + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadataQueryAPIHelper.queryMetadata = jest + .fn() + .mockReturnValueOnce(Promise.resolve(metadataQueryResponse)); + metadataQueryAPIHelper.getTemplateSchemaInfo = jest.fn().mockReturnValueOnce(Promise.resolve(template)); + metadataQueryAPIHelper.getDataWithTypes = jest.fn().mockReturnValueOnce(dataWithTypes); + await metadataQueryAPIHelper.fetchMetadataQueryResults(mdQuery, successCallback, errorCallback); + expect(metadataQueryAPIHelper.queryMetadata).toBeCalled(); + expect(metadataQueryAPIHelper.getTemplateSchemaInfo).toBeCalledWith(metadataQueryResponse); + expect(metadataQueryAPIHelper.getDataWithTypes).toBeCalledWith(template); + expect(successCallback).toBeCalledWith(dataWithTypes, template); + expect(errorCallback).not.toHaveBeenCalled(); + }); + + test('should call error callback when the promise chain throws exception during API data fetch', async () => { + const err = new Error(); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadataQueryAPIHelper.queryMetadata = jest + .fn() + .mockReturnValueOnce(Promise.resolve(metadataQueryResponse)); + metadataQueryAPIHelper.getTemplateSchemaInfo = jest.fn().mockReturnValueOnce(Promise.reject(err)); + metadataQueryAPIHelper.getDataWithTypes = jest.fn().mockReturnValueOnce(dataWithTypes); + await metadataQueryAPIHelper.fetchMetadataQueryResults(mdQuery, successCallback, errorCallback); + expect(metadataQueryAPIHelper.queryMetadata).toBeCalled(); + expect(metadataQueryAPIHelper.getTemplateSchemaInfo).toBeCalledWith(metadataQueryResponse); + expect(metadataQueryAPIHelper.getDataWithTypes).not.toHaveBeenCalled(); + expect(successCallback).not.toHaveBeenCalled(); + expect(errorCallback).toBeCalledWith(err); + }); + }); + + describe('createJSONPatchOperations()', () => { + const field = 'amount'; + const testOp = { + op: JSON_PATCH_OP_TEST, + path: `/${field}`, + value: 100, + }; + + const addOp = { + op: JSON_PATCH_OP_ADD, + path: `/${field}`, + value: 200, + }; + + const replaceOp = { + op: JSON_PATCH_OP_REPLACE, + path: `/${field}`, + value: 200, + }; + + const removeOp = { + op: JSON_PATCH_OP_REMOVE, + path: `/${field}`, + }; + + test.each` + oldValue | newValue | ops + ${undefined} | ${200} | ${[addOp]} + ${null} | ${200} | ${[addOp]} + ${100} | ${200} | ${[testOp, replaceOp]} + ${100} | ${undefined} | ${[testOp, removeOp]} + ${100} | ${null} | ${[testOp, removeOp]} + `('should return valid JSON patch object', ({ oldValue, newValue, ops }) => { + expect(metadataQueryAPIHelper.createJSONPatchOperations(field, oldValue, newValue)).toEqual(ops); + }); + }); + + describe('getMetadataQueryFields()', () => { + test('should get metadata instance fields array from the query', () => { + const expectedResponse = ['type', 'year', 'approved']; + expect(metadataQueryAPIHelper.getMetadataQueryFields()).toEqual(expectedResponse); + }); + }); + + describe('updateMetadata()', () => { + test('should update the metadata by calling Metadata api function', async () => { + const file = 'file'; + const field = 'amount'; + const oldValue = 100; + const newValue = 200; + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + const JSONPatchOps = { jsonPatch: 'jsonPatch' }; + metadataQueryAPIHelper.createJSONPatchOperations = jest.fn().mockReturnValueOnce(JSONPatchOps); + + await metadataQueryAPIHelper.updateMetadata( + file, + field, + oldValue, + newValue, + successCallback, + errorCallback, + ); + expect(metadataQueryAPIHelper.createJSONPatchOperations).toHaveBeenCalledWith(field, oldValue, newValue); + expect(metadataQueryAPIHelper.api.getMetadataAPI().updateMetadata).toHaveBeenCalledWith( + file, + template, + JSONPatchOps, + successCallback, + errorCallback, + ); + }); + }); + + describe('verifyQueryFields()', () => { + const mdQueryWithEmptyFields = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + query: 'query', + query_params: {}, + }; + const mdQueryWithoutNameField = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + query: 'query', + query_params: {}, + fields: ['created_at', 'metadata.enterprise_1234.templateKey.type'], + }; + const mdQueryWithNameField = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + query: 'query', + query_params: {}, + fields: [FIELD_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'], + }; + const mdQueryWithBothFields = { + ancestor_folder_id: '672838458', + from: 'enterprise_1234.templateKey', + query: 'query', + query_params: {}, + fields: [FIELD_NAME, FIELD_EXTENSION, 'metadata.enterprise_1234.templateKey.type'], + }; + test.each` + index | metadataQuery + ${1} | ${mdQueryWithEmptyFields} + ${2} | ${mdQueryWithoutNameField} + ${3} | ${mdQueryWithNameField} + ${4} | ${mdQueryWithoutExtensionField} + ${5} | ${mdQueryWithBothFields} + `( + 'should verify the metadata query object and add required fields if necessary', + ({ 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_EXTENSION)).toBe(true); + + if (index === 2) { + // Verify "name" and "extension" are added to pre-existing fields + expect(updatedMetadataQuery.fields).toEqual([ + ...mdQueryWithoutNameField.fields, + FIELD_NAME, + FIELD_EXTENSION, + ]); + } + + if (index === 4) { + // Verify "extension" is added when "name" exists but "extension" doesn't + expect(updatedMetadataQuery.fields).toEqual([ + ...mdQueryWithoutExtensionField.fields, + FIELD_EXTENSION, + ]); + } + + if (index === 5) { + // No change, original query has all necessary fields + expect(updatedMetadataQuery.fields).toEqual(mdQueryWithBothFields.fields); + } + }, + ); + }); +}); diff --git a/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx new file mode 100644 index 0000000000..16df4b3a0e --- /dev/null +++ b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { render, screen } from '../../../test-utils/testing-library'; +import MetadataViewContainer, { MetadataViewContainerProps } from '../MetadataViewContainer'; +import type { Collection } from '../../../common/types/core'; +import type { MetadataTemplate, MetadataTemplateField } from '../../../common/types/metadata'; + +describe('elements/content-explorer/MetadataViewContainer', () => { + const mockItems = [ + { id: '1', name: 'File 1.txt', type: 'file' }, + { id: '2', name: 'File 2.pdf', type: 'file' }, + ]; + + const mockMetadataTemplateFields: MetadataTemplateField[] = [ + { + id: 'field1', + key: ' name', + displayName: 'Name', + type: 'string', + }, + { + id: 'field1', + key: 'industry', + displayName: 'Industry', + type: 'enum', + options: [ + { key: 'tech', id: 'tech1' }, + { key: 'finance', id: 'finance1' }, + ], + }, + ]; + + const mockMetadataTemplate: MetadataTemplate = { + id: 'template1', + scope: 'enterprise', + templateKey: 'testTemplate', + displayName: 'Test Template', + fields: mockMetadataTemplateFields, + }; + + const mockCollection: Collection = { + id: '0', + items: mockItems, + percentLoaded: 100, + }; + + const defaultProps: MetadataViewContainerProps = { + currentCollection: mockCollection, + columns: [ + { + textValue: 'Name', + id: 'name', + type: 'string', + allowsSorting: true, + minWidth: 250, + maxWidth: 250, + isRowHeader: true, + }, + { + textValue: 'Industry', + id: 'industry', + type: 'string', + allowsSorting: true, + minWidth: 250, + maxWidth: 250, + }, + ], + metadataTemplate: mockMetadataTemplate, + }; + + const renderComponent = (props: Partial = {}) => { + return render(); + }; + + test('should render MetadataView component', () => { + renderComponent(); + + expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Name' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Industry' })).toBeInTheDocument(); + expect(screen.getByText('File 1.txt')).toBeInTheDocument(); + expect(screen.getByText('File 2.pdf')).toBeInTheDocument(); + }); +}); diff --git a/src/elements/content-explorer/stories/MetadataView.stories.tsx b/src/elements/content-explorer/stories/MetadataView.stories.tsx new file mode 100644 index 0000000000..cb19394aca --- /dev/null +++ b/src/elements/content-explorer/stories/MetadataView.stories.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import get from 'lodash/get'; +import { http, HttpResponse } from 'msw'; +import type { Meta, StoryObj } from '@storybook/react'; +import ContentExplorer from '../ContentExplorer'; +import { DEFAULT_HOSTNAME_API } from '../../../constants'; +import { mockMetadata, mockSchema } from '../../common/__mocks__/mockMetadata'; + +const EID = '0'; +const templateName = 'templateName'; +const metadataSource = `enterprise_${EID}.${templateName}`; +const metadataSourceFieldName = `metadata.${metadataSource}`; + +const metadataQuery = { + from: metadataSource, + + // // Filter items in the folder by existing metadata key + // query: 'key = :arg1', + // + // // Display items with value + // query_params: { arg1: 'value' }, + + ancestor_folder_id: '0', + fields: [ + `name`, + `${metadataSourceFieldName}.industry`, + `${metadataSourceFieldName}.last_contacted_at`, + `${metadataSourceFieldName}.role`, + ], +}; + +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}`, + type: field.type, + allowsSorting: true, + minWidth: 200, + maxWidth: 200, + cellRenderer: (item, column) => { + const dateValue = get(item, column.id); + return dateValue ? new Date(dateValue).toLocaleDateString() : ''; + }, + }; + } + + return { + textValue: field.displayName, + id: `${metadataSourceFieldName}.${field.key}`, + type: field.type, + allowsSorting: true, + minWidth: 200, + maxWidth: 200, + }; +}); +const defaultView = 'metadata'; // Required prop to paint the metadata view. If not provided, you'll get regular folder view. + +type Story = StoryObj; + +export const metadataView: Story = { + args: { + metadataViewProps: { + columns, + tableProps: { + isSelectAllEnabled: true, + }, + }, + metadataQuery, + fieldsToShow, + defaultView, + features: { + contentExplorer: { + metadataViewV2: true, + }, + }, + }, + render: args => { + return ( +
+ +
+ ); + }, +}; + +const meta: Meta = { + title: 'Elements/ContentExplorer/MetadataView', + component: ContentExplorer, + args: { + features: global.FEATURE_FLAGS, + rootFolderId: global.FOLDER_ID, + token: global.TOKEN, + }, + parameters: { + msw: { + handlers: [ + http.post(`${DEFAULT_HOSTNAME_API}/2.0/metadata_queries/execute_read`, () => { + return HttpResponse.json(mockMetadata); + }), + http.get(`${DEFAULT_HOSTNAME_API}/2.0/metadata_templates/enterprise/templateName/schema`, () => { + return HttpResponse.json(mockSchema); + }), + ], + }, + }, +}; + +export default meta; 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 ccd7a5b0dd..10c066e436 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { http, HttpResponse } from 'msw'; import type { Meta, StoryObj } from '@storybook/react'; import ContentExplorer from '../../ContentExplorer'; @@ -33,6 +34,15 @@ const fieldsToShow = [ { key: `${metadataSourceFieldName}.last_contacted_at`, canEdit: true }, { key: `${metadataSourceFieldName}.role`, canEdit: true }, ]; + +const columns = mockSchema.fields.map(field => ({ + textValue: field.displayName, + id: `${metadataSourceFieldName}.${field.key}`, + type: field.type, + allowSorting: true, + minWidth: 150, + maxWidth: 150, +})); const defaultView = 'metadata'; // Required prop to paint the metadata view. If not provided, you'll get regular folder view. type Story = StoryObj; @@ -47,6 +57,9 @@ export const metadataView: Story = { export const withNewMetadataView: Story = { args: { + metadataViewProps: { + columns, + }, metadataQuery, fieldsToShow, defaultView, @@ -56,10 +69,17 @@ export const withNewMetadataView: Story = { }, }, }, + render: args => { + return ( +
+ +
+ ); + }, }; const meta: Meta = { - title: 'Elements/ContentExplorer/tests/ContentExplorer/visual/MetadataView', + title: 'Elements/ContentExplorer/tests/MetadataView/visual', component: ContentExplorer, args: { features: global.FEATURE_FLAGS, diff --git a/yarn.lock b/yarn.lock index fb5ec5e2de..4b5ed6fb4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1379,14 +1379,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": - version "7.24.8" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.8.tgz#5d958c3827b13cc6d05e038c07fb2e5e3420d82e" - integrity sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA== - dependencies: - regenerator-runtime "^0.14.0" - -"@babel/runtime@^7.13.10": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.15.4", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.28.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.2.tgz#2ae5a9d51cc583bd1f5673b3bb70d6d819682473" integrity sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA== @@ -1482,6 +1475,11 @@ resolved "https://registry.yarnpkg.com/@box/box-ai-content-answers/-/box-ai-content-answers-0.124.1.tgz#3248b33bb0605b376b1ff7acf1ac658055aa4cac" integrity sha512-NrQ+JWi1JCO5vdLi/kdJEmFOCKpE8Xa4mo6D/aXtREo+acEEC/VE8vJk6jgGdyrgP6wvgc//TCL2d2L51Tr/jg== +"@box/box-item-type-selector@^0.61.12": + version "0.61.16" + resolved "https://registry.yarnpkg.com/@box/box-item-type-selector/-/box-item-type-selector-0.61.16.tgz#476ba5d58e177f9967a064077273d0d690b887f0" + integrity sha512-0wiiqX4/x6K7lX7QfbQYxW3ELTV+9rYdRxdV2IZqE+3fzrGiSgucYtRLkyfeqsBRUFDQSfYRXHA+sE4ZdTb/fA== + "@box/cldr-data@^34.2.0": version "34.8.0" resolved "https://registry.yarnpkg.com/@box/cldr-data/-/cldr-data-34.8.0.tgz#36e6ddcea8e20653326aba2e0d13e07f34b7704f" @@ -1514,10 +1512,20 @@ resolved "https://registry.yarnpkg.com/@box/languages/-/languages-1.1.2.tgz#cd4266b3da62da18560d881e10b429653186be29" integrity sha512-d64TGosx+KRmrLZj4CIyLp42LUiEbgBJ8n8cviMQwTJmfU0g+UwZqLjmQZR1j+Q9D64yV4xHzY9K1t5nInWWeQ== -"@box/metadata-editor@^0.122.0": - version "0.122.2" - resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-0.122.2.tgz#a38c30aa8997a89b06ad73b27f21c1daed3e8abc" - integrity sha512-21VO3TbVy8CMgk5FnCUeYZHCsme2Ykw/sGfHQ0X5vDHyKbq4E2YrejIQQH3jbQxnbl9RKNf45E9cif/lFwJn6Q== +"@box/metadata-editor@^0.122.12": + version "0.122.16" + resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-0.122.16.tgz#d120602dcc39a6b4cd6f441ddd5fdc4b2f2862a4" + integrity sha512-fy43Z9fr6+t0hO+tlVDX4A890K0mos78GqHeZp9fGDH+lQsjHBHz3DDvcbyfmkCrf3Dbg15wrvBO0Pa4FaCSvw== + +"@box/metadata-filter@^1.16.12": + version "1.18.0" + resolved "https://registry.yarnpkg.com/@box/metadata-filter/-/metadata-filter-1.18.0.tgz#c6e2a69f1ce9919063243fb451a97b4fc78ed019" + integrity sha512-us2njX6ade6iYeJekaerUv67d+yAob+o14ouYkfHzRFjLyfxDxB6ycTy/0jHwXLQYREXSZfIu6L+INEgbT2VAA== + +"@box/metadata-view@^0.29.4": + version "0.29.4" + resolved "https://registry.yarnpkg.com/@box/metadata-view/-/metadata-view-0.29.4.tgz#03c42c0e32e9fc9895a5b56bc4a97daafeec8ca9" + integrity sha512-0CPQ7PE6uiW4hO3EOfpyLu1nD0u4UXwCMYsdHjQTHSeqgpmT8sx7ZlZ/7or+bwlekMqijdJ2CqbJhz3WsIlIfQ== "@box/react-virtualized@^9.22.3-rc-box.10": version "9.22.3-rc-box.10" @@ -3648,6 +3656,26 @@ "@react-types/shared" "^3.28.0" "@swc/helpers" "^0.5.0" +"@react-aria/autocomplete@3.0.0-beta.6": + version "3.0.0-beta.6" + resolved "https://registry.yarnpkg.com/@react-aria/autocomplete/-/autocomplete-3.0.0-beta.6.tgz#d2894665c9d0082ea680d5ab9a3cee9c634a5e95" + integrity sha512-/i0Y1nJNSDk5k49tlApYfFCylZO597KQSMy4AbG60W6VNUw51QrmY9bzO3zdGAEVdPSuMys/72KwvV6LOpllyQ== + dependencies: + "@react-aria/combobox" "^3.13.0" + "@react-aria/focus" "^3.21.0" + "@react-aria/i18n" "^3.12.11" + "@react-aria/interactions" "^3.25.4" + "@react-aria/listbox" "^3.14.7" + "@react-aria/searchfield" "^3.8.7" + "@react-aria/textfield" "^3.18.0" + "@react-aria/utils" "^3.30.0" + "@react-stately/autocomplete" "3.0.0-beta.3" + "@react-stately/combobox" "^3.11.0" + "@react-types/autocomplete" "3.0.0-alpha.33" + "@react-types/button" "^3.13.0" + "@react-types/shared" "^3.31.0" + "@swc/helpers" "^0.5.0" + "@react-aria/breadcrumbs@^3.5.22", "@react-aria/breadcrumbs@^3.5.27": version "3.5.27" resolved "https://registry.yarnpkg.com/@react-aria/breadcrumbs/-/breadcrumbs-3.5.27.tgz#594e6190518baa3da324a79e24a539e4a9606f6b" @@ -3718,6 +3746,18 @@ "@swc/helpers" "^0.5.0" use-sync-external-store "^1.4.0" +"@react-aria/collections@3.0.0-rc.4": + version "3.0.0-rc.4" + resolved "https://registry.yarnpkg.com/@react-aria/collections/-/collections-3.0.0-rc.4.tgz#8c66d33ca9acd5b6da5ec47de3343bc8a4fd2685" + integrity sha512-efcQW/Kly5ebS2kWrVRBD7yEl3b0FdQE/dDL/87skVMW0Vh6AtUgCShZfcOcGAIqvG7m6QItdUHwAilDA61riQ== + dependencies: + "@react-aria/interactions" "^3.25.4" + "@react-aria/ssr" "^3.9.10" + "@react-aria/utils" "^3.30.0" + "@react-types/shared" "^3.31.0" + "@swc/helpers" "^0.5.0" + use-sync-external-store "^1.4.0" + "@react-aria/color@^3.0.5", "@react-aria/color@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@react-aria/color/-/color-3.1.0.tgz#d131deef07ef66881e1dad4ed9d4e4f41b5242bc" @@ -4327,7 +4367,7 @@ "@swc/helpers" "^0.5.0" clsx "^2.0.0" -"@react-aria/virtualizer@^4.1.3": +"@react-aria/virtualizer@^4.1.3", "@react-aria/virtualizer@^4.1.8": version "4.1.8" resolved "https://registry.yarnpkg.com/@react-aria/virtualizer/-/virtualizer-4.1.8.tgz#f0d371802aaf49290501125062d7b281caa64ee3" integrity sha512-dwaJuqjtpVKTaWJS+PEe+tymqVzOjY8cZLvmSDC4uUizHOUh+O/NvoKWtwSQnB4/GxIEvdgLxYTTvVTf8jdKgw== @@ -4357,6 +4397,14 @@ "@react-stately/utils" "^3.10.4" "@swc/helpers" "^0.5.0" +"@react-stately/autocomplete@3.0.0-beta.3": + version "3.0.0-beta.3" + resolved "https://registry.yarnpkg.com/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.3.tgz#c7a96877915b7bff8804c5a353fb0b1ffecf0401" + integrity sha512-YfP/TrvkOCp6j7oqpZxJSvmSeXn+XtbKSOiBOuo+m2zCIhW2ncThmDB9uAUOkpmikDv/LkGKni40RQE8USdGdA== + dependencies: + "@react-stately/utils" "^3.10.8" + "@swc/helpers" "^0.5.0" + "@react-stately/calendar@^3.8.3": version "3.8.3" resolved "https://registry.yarnpkg.com/@react-stately/calendar/-/calendar-3.8.3.tgz#749d8965d3825fdffd4b728b69fbd4c5099e667d" @@ -4483,7 +4531,7 @@ "@react-types/shared" "^3.31.0" "@swc/helpers" "^0.5.0" -"@react-stately/layout@^4.2.1": +"@react-stately/layout@^4.2.1", "@react-stately/layout@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@react-stately/layout/-/layout-4.4.0.tgz#3203204e8c745d0046d814f77cef938f0e8a3ba2" integrity sha512-PGpJBCo8yzasdYVGHFp/vHdzaJsagUOSc/bAQubVpKpKK+RVgSpk2uCo1O8sYjI5MxSVrhlhqGbVfV1O6Tqksw== @@ -4677,6 +4725,15 @@ "@react-types/searchfield" "^3.6.0" "@react-types/shared" "^3.28.0" +"@react-types/autocomplete@3.0.0-alpha.33": + version "3.0.0-alpha.33" + resolved "https://registry.yarnpkg.com/@react-types/autocomplete/-/autocomplete-3.0.0-alpha.33.tgz#dca759dd07921a55e12d5a33d54cdd1a8b892219" + integrity sha512-443avwJleeBmTR96WduQpq+D4murkmZLueen/2aazRST9nylN7u8w0DSW+84c9ENroSpfHI6Nf7epmg1LxLaOA== + dependencies: + "@react-types/combobox" "^3.13.7" + "@react-types/searchfield" "^3.6.4" + "@react-types/shared" "^3.31.0" + "@react-types/breadcrumbs@^3.7.15": version "3.7.15" resolved "https://registry.yarnpkg.com/@react-types/breadcrumbs/-/breadcrumbs-3.7.15.tgz#2b8b21aec32d2e328a4fa8b00f04f12908f6fed1" @@ -4740,7 +4797,7 @@ "@react-types/overlays" "^3.9.0" "@react-types/shared" "^3.31.0" -"@react-types/form@^3.7.10": +"@react-types/form@^3.7.10", "@react-types/form@^3.7.14": version "3.7.14" resolved "https://registry.yarnpkg.com/@react-types/form/-/form-3.7.14.tgz#d91e3d7199cfef620a288784b6174f56ce14a3a0" integrity sha512-P+FXOQR/ISxLfBbCwgttcR1OZGqOknk7Ksgrxf7jpc4PuyUC048Jf+FcG+fARhoUeNEhv6kBXI5fpAB6xqnDhA== @@ -5179,6 +5236,18 @@ dependencies: defer-to-connect "^2.0.0" +"@tanstack/react-virtual@^3.13.12": + version "3.13.12" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz#d372dc2783739cc04ec1a728ca8203937687a819" + integrity sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA== + dependencies: + "@tanstack/virtual-core" "3.13.12" + +"@tanstack/virtual-core@3.13.12": + version "3.13.12" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz#1dff176df9cc8f93c78c5e46bcea11079b397578" + integrity sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA== + "@testing-library/dom@^10.4.0": version "10.4.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.0.tgz#82a9d9462f11d240ecadbf406607c6ceeeff43a8" @@ -8800,12 +8869,7 @@ decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== -decimal.js@^10.4.2: - version "10.4.3" - resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" - integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== - -decimal.js@^10.4.3: +decimal.js@^10.4.2, decimal.js@^10.4.3: version "10.6.0" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== @@ -16441,6 +16505,40 @@ react-aria-components@1.7.1: react-stately "^3.36.1" use-sync-external-store "^1.4.0" +react-aria-components@^1.10.1: + version "1.11.0" + resolved "https://registry.yarnpkg.com/react-aria-components/-/react-aria-components-1.11.0.tgz#2ba03fd63ac59d51ccdea6960da2004ba81cc1f0" + integrity sha512-+NxjfCiswbssoCNPJ1H5NEPnM2G7whM5bZSjkSUPXS3ZbbqQ1KSmSWHT34V4mrU+kpFfEZeZ/6E6GBYfugndig== + dependencies: + "@internationalized/date" "^3.8.2" + "@internationalized/string" "^3.2.7" + "@react-aria/autocomplete" "3.0.0-beta.6" + "@react-aria/collections" "3.0.0-rc.4" + "@react-aria/dnd" "^3.11.0" + "@react-aria/focus" "^3.21.0" + "@react-aria/interactions" "^3.25.4" + "@react-aria/live-announcer" "^3.4.4" + "@react-aria/overlays" "^3.28.0" + "@react-aria/ssr" "^3.9.10" + "@react-aria/toolbar" "3.0.0-beta.19" + "@react-aria/utils" "^3.30.0" + "@react-aria/virtualizer" "^4.1.8" + "@react-stately/autocomplete" "3.0.0-beta.3" + "@react-stately/layout" "^4.4.0" + "@react-stately/selection" "^3.20.4" + "@react-stately/table" "^3.14.4" + "@react-stately/utils" "^3.10.8" + "@react-stately/virtualizer" "^4.4.2" + "@react-types/form" "^3.7.14" + "@react-types/grid" "^3.3.4" + "@react-types/shared" "^3.31.0" + "@react-types/table" "^3.13.2" + "@swc/helpers" "^0.5.0" + client-only "^0.0.1" + react-aria "^3.42.0" + react-stately "^3.40.0" + use-sync-external-store "^1.4.0" + react-aria@3.38.1: version "3.38.1" resolved "https://registry.yarnpkg.com/react-aria/-/react-aria-3.38.1.tgz#5052848a6481bc4bb8cae004ac0656151a973585" @@ -16489,7 +16587,7 @@ react-aria@3.38.1: "@react-aria/visually-hidden" "^3.8.21" "@react-types/shared" "^3.28.0" -react-aria@^3.38.1: +react-aria@^3.38.1, react-aria@^3.42.0: version "3.42.0" resolved "https://registry.yarnpkg.com/react-aria/-/react-aria-3.42.0.tgz#7e306f8fa156873bc2e3d17d7cf4848747805df5" integrity sha512-lZF1tVmcO6mTWBHpmo4r58lBxIkt/DeF1gu5vrLv2lF4H213VGdSIG8ogQgMc2NaLHK720wafYVM2m5pRUIKdg== @@ -16780,7 +16878,7 @@ react-shallow-renderer@^16.15.0: object-assign "^4.1.1" react-is "^16.12.0 || ^17.0.0 || ^18.0.0" -react-stately@^3.36.1: +react-stately@^3.36.1, react-stately@^3.40.0: version "3.40.0" resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.40.0.tgz#1ca17aead2e65217c58dbad79bb21175d7af1d31" integrity sha512-Icg2q1pxTskx2dph3cFUu9RUQcInq25WZfUcKroX1Kl4jWxBobnfMvuxvJHHkysJh77IsnLmhF3+8If5oCoMFQ== @@ -17007,7 +17105,7 @@ regenerator-runtime@^0.13.4: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== -regenerator-runtime@^0.14.0, regenerator-runtime@^0.14.1: +regenerator-runtime@^0.14.1: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== @@ -19040,16 +19138,11 @@ tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== -tslib@^2.0.1, tslib@^2.0.3, tslib@^2.3.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" - integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== - tty-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811"