box-ui-elements 24.0.0-beta.4 → 24.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/explorer.css +1 -1
  2. package/dist/explorer.js +1 -1
  3. package/dist/openwith.js +1 -1
  4. package/dist/picker.js +1 -1
  5. package/dist/preview.js +1 -1
  6. package/dist/sharing.js +1 -1
  7. package/dist/sidebar.js +1 -1
  8. package/dist/uploader.js +1 -1
  9. package/es/api/Metadata.js +98 -13
  10. package/es/api/Metadata.js.flow +110 -12
  11. package/es/api/Metadata.js.map +1 -1
  12. package/es/elements/common/messages.js +16 -0
  13. package/es/elements/common/messages.js.flow +25 -0
  14. package/es/elements/common/messages.js.map +1 -1
  15. package/es/elements/content-explorer/Content.js +5 -2
  16. package/es/elements/content-explorer/Content.js.map +1 -1
  17. package/es/elements/content-explorer/ContentExplorer.js +31 -6
  18. package/es/elements/content-explorer/ContentExplorer.js.map +1 -1
  19. package/es/elements/content-explorer/MetadataQueryAPIHelper.js +164 -10
  20. package/es/elements/content-explorer/MetadataQueryAPIHelper.js.map +1 -1
  21. package/es/elements/content-explorer/MetadataQueryBuilder.js +115 -0
  22. package/es/elements/content-explorer/MetadataQueryBuilder.js.map +1 -0
  23. package/es/elements/content-explorer/MetadataSidePanel.js +40 -14
  24. package/es/elements/content-explorer/MetadataSidePanel.js.map +1 -1
  25. package/es/elements/content-explorer/MetadataViewContainer.js +133 -36
  26. package/es/elements/content-explorer/MetadataViewContainer.js.map +1 -1
  27. package/es/elements/content-explorer/stories/MetadataView.stories.js +3 -25
  28. package/es/elements/content-explorer/stories/MetadataView.stories.js.map +1 -1
  29. package/es/elements/content-explorer/stories/tests/MetadataView-visual.stories.js +65 -29
  30. package/es/elements/content-explorer/stories/tests/MetadataView-visual.stories.js.map +1 -1
  31. package/es/elements/content-explorer/utils.js +140 -12
  32. package/es/elements/content-explorer/utils.js.map +1 -1
  33. package/es/src/elements/common/__mocks__/mockMetadata.d.ts +8 -24
  34. package/es/src/elements/content-explorer/Content.d.ts +4 -3
  35. package/es/src/elements/content-explorer/ContentExplorer.d.ts +19 -6
  36. package/es/src/elements/content-explorer/MetadataQueryAPIHelper.d.ts +22 -3
  37. package/es/src/elements/content-explorer/MetadataQueryBuilder.d.ts +27 -0
  38. package/es/src/elements/content-explorer/MetadataSidePanel.d.ts +6 -3
  39. package/es/src/elements/content-explorer/MetadataViewContainer.d.ts +10 -4
  40. package/es/src/elements/content-explorer/__tests__/MetadataQueryBuilder.test.d.ts +1 -0
  41. package/es/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.d.ts +1 -0
  42. package/es/src/elements/content-explorer/utils.d.ts +9 -3
  43. package/i18n/bn-IN.js +4 -0
  44. package/i18n/bn-IN.properties +12 -0
  45. package/i18n/da-DK.js +4 -0
  46. package/i18n/da-DK.properties +12 -0
  47. package/i18n/de-DE.js +5 -1
  48. package/i18n/de-DE.properties +12 -0
  49. package/i18n/en-AU.js +4 -0
  50. package/i18n/en-AU.properties +12 -0
  51. package/i18n/en-CA.js +4 -0
  52. package/i18n/en-CA.properties +12 -0
  53. package/i18n/en-GB.js +4 -0
  54. package/i18n/en-GB.properties +12 -0
  55. package/i18n/en-US.js +4 -0
  56. package/i18n/en-US.properties +8 -0
  57. package/i18n/en-x-pseudo.js +4 -0
  58. package/i18n/es-419.js +5 -1
  59. package/i18n/es-419.properties +12 -0
  60. package/i18n/es-ES.js +5 -1
  61. package/i18n/es-ES.properties +12 -0
  62. package/i18n/fi-FI.js +4 -0
  63. package/i18n/fi-FI.properties +12 -0
  64. package/i18n/fr-CA.js +4 -0
  65. package/i18n/fr-CA.properties +12 -0
  66. package/i18n/fr-FR.js +4 -0
  67. package/i18n/fr-FR.properties +12 -0
  68. package/i18n/hi-IN.js +4 -0
  69. package/i18n/hi-IN.properties +12 -0
  70. package/i18n/it-IT.js +4 -0
  71. package/i18n/it-IT.properties +12 -0
  72. package/i18n/ja-JP.js +6 -2
  73. package/i18n/ja-JP.properties +14 -2
  74. package/i18n/ko-KR.js +4 -0
  75. package/i18n/ko-KR.properties +12 -0
  76. package/i18n/nb-NO.js +4 -0
  77. package/i18n/nb-NO.properties +12 -0
  78. package/i18n/nl-NL.js +4 -0
  79. package/i18n/nl-NL.properties +12 -0
  80. package/i18n/pl-PL.js +4 -0
  81. package/i18n/pl-PL.properties +12 -0
  82. package/i18n/pt-BR.js +4 -0
  83. package/i18n/pt-BR.properties +12 -0
  84. package/i18n/ru-RU.js +5 -1
  85. package/i18n/ru-RU.properties +12 -0
  86. package/i18n/sv-SE.js +4 -0
  87. package/i18n/sv-SE.properties +12 -0
  88. package/i18n/tr-TR.js +5 -1
  89. package/i18n/tr-TR.properties +12 -0
  90. package/i18n/zh-CN.js +4 -0
  91. package/i18n/zh-CN.properties +12 -0
  92. package/i18n/zh-TW.js +4 -0
  93. package/i18n/zh-TW.properties +12 -0
  94. package/package.json +3 -3
  95. package/src/api/Metadata.js +110 -12
  96. package/src/api/__tests__/Metadata.test.js +120 -0
  97. package/src/elements/common/__mocks__/mockMetadata.ts +7 -11
  98. package/src/elements/common/messages.js +25 -0
  99. package/src/elements/content-explorer/Content.tsx +9 -2
  100. package/src/elements/content-explorer/ContentExplorer.tsx +71 -17
  101. package/src/elements/content-explorer/MetadataQueryAPIHelper.ts +199 -8
  102. package/src/elements/content-explorer/MetadataQueryBuilder.ts +159 -0
  103. package/src/elements/content-explorer/MetadataSidePanel.tsx +55 -14
  104. package/src/elements/content-explorer/MetadataViewContainer.tsx +164 -29
  105. package/src/elements/content-explorer/__tests__/Content.test.tsx +1 -0
  106. package/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +38 -7
  107. package/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts +428 -12
  108. package/src/elements/content-explorer/__tests__/MetadataQueryBuilder.test.ts +419 -0
  109. package/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx +145 -3
  110. package/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx +413 -9
  111. package/src/elements/content-explorer/stories/MetadataView.stories.tsx +3 -21
  112. package/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +56 -21
  113. package/src/elements/content-explorer/utils.ts +150 -13
@@ -0,0 +1,159 @@
1
+ import isNil from 'lodash/isNil';
2
+
3
+ type QueryResult = {
4
+ queryParams: { [key: string]: number | Date | string };
5
+ queries: string[];
6
+ keysGenerated: number;
7
+ };
8
+
9
+ // Custom type for range filters
10
+ type SimpleRangeType = {
11
+ range: {
12
+ gt: number | string;
13
+ lt: number | string;
14
+ };
15
+ };
16
+
17
+ // Union type for filter values
18
+ type SimpleFilterValue = string[] | SimpleRangeType;
19
+
20
+ export const mergeQueryParams = (
21
+ targetParams: { [key: string]: number | Date | string },
22
+ sourceParams: { [key: string]: number | Date | string },
23
+ ): { [key: string]: number | Date | string } => {
24
+ return { ...targetParams, ...sourceParams };
25
+ };
26
+
27
+ export const mergeQueries = (targetQueries: string[], sourceQueries: string[]): string[] => {
28
+ return [...targetQueries, ...sourceQueries];
29
+ };
30
+
31
+ const generateArgKey = (key: string, index: number): string => {
32
+ const purifyKey = key.replace(/[^\w]/g, '_');
33
+ return `arg_${purifyKey}_${index}`;
34
+ };
35
+
36
+ const escapeValue = (value: string): string => value.replace(/([_%])/g, '\\$1');
37
+
38
+ export const getStringFilter = (filterValue: string, fieldKey: string, argIndexStart: number): QueryResult => {
39
+ let currentArgIndex = argIndexStart;
40
+
41
+ const argKey = generateArgKey(fieldKey, (currentArgIndex += 1));
42
+ return {
43
+ queryParams: { [argKey]: `%${escapeValue(filterValue)}%` },
44
+ queries: [`(${fieldKey} ILIKE :${argKey})`],
45
+ keysGenerated: currentArgIndex - argIndexStart,
46
+ };
47
+ };
48
+
49
+ const isInvalid = (value: number | string) => {
50
+ return isNil(value) || value === '';
51
+ };
52
+
53
+ export const getRangeFilter = (
54
+ filterValue: SimpleFilterValue,
55
+ fieldKey: string,
56
+ argIndexStart: number,
57
+ ): QueryResult => {
58
+ let currentArgIndex = argIndexStart;
59
+
60
+ if (filterValue && typeof filterValue === 'object' && 'range' in filterValue && filterValue.range) {
61
+ const { gt, lt } = filterValue.range;
62
+ const queryParams: { [key: string]: number | string } = {};
63
+ const queries: string[] = [];
64
+
65
+ if (!isInvalid(gt) && !isInvalid(lt)) {
66
+ // Both gt and lt: between values
67
+ const argKeyGt = generateArgKey(fieldKey, (currentArgIndex += 1));
68
+ const argKeyLt = generateArgKey(fieldKey, (currentArgIndex += 1));
69
+ queryParams[argKeyGt] = gt;
70
+ queryParams[argKeyLt] = lt;
71
+ queries.push(`(${fieldKey} >= :${argKeyGt} AND ${fieldKey} <= :${argKeyLt})`);
72
+ } else if (!isInvalid(gt)) {
73
+ // Only gt: greater than
74
+ const argKey = generateArgKey(fieldKey, (currentArgIndex += 1));
75
+ queryParams[argKey] = gt;
76
+ queries.push(`(${fieldKey} >= :${argKey})`);
77
+ } else if (!isInvalid(lt)) {
78
+ // Only lt: less than
79
+ const argKey = generateArgKey(fieldKey, (currentArgIndex += 1));
80
+ queryParams[argKey] = lt;
81
+ queries.push(`(${fieldKey} <= :${argKey})`);
82
+ }
83
+
84
+ return {
85
+ queryParams,
86
+ queries,
87
+ keysGenerated: currentArgIndex - argIndexStart,
88
+ };
89
+ }
90
+ return {
91
+ queryParams: {},
92
+ queries: [],
93
+ keysGenerated: 0,
94
+ };
95
+ };
96
+
97
+ export const getSelectFilter = (filterValue: string[], fieldKey: string, argIndexStart: number): QueryResult => {
98
+ if (!Array.isArray(filterValue) || filterValue.length === 0) {
99
+ return {
100
+ queryParams: {},
101
+ queries: [],
102
+ keysGenerated: 0,
103
+ };
104
+ }
105
+
106
+ let currentArgIndex = argIndexStart;
107
+
108
+ const multiSelectQueryParams = Object.fromEntries(
109
+ filterValue.map(value => {
110
+ currentArgIndex += 1;
111
+ return [generateArgKey(fieldKey, currentArgIndex), String(value)];
112
+ }),
113
+ );
114
+
115
+ return {
116
+ queryParams: multiSelectQueryParams,
117
+ queries: [
118
+ `(${fieldKey === 'mimetype-filter' ? 'item.extension' : fieldKey} HASANY (${Object.keys(
119
+ multiSelectQueryParams,
120
+ )
121
+ .map(argKey => `:${argKey}`)
122
+ .join(', ')}))`,
123
+ ],
124
+ keysGenerated: currentArgIndex - argIndexStart,
125
+ };
126
+ };
127
+
128
+ export const getMimeTypeFilter = (filterValue: string[], fieldKey: string, argIndexStart: number): QueryResult => {
129
+ if (!Array.isArray(filterValue) || filterValue.length === 0) {
130
+ return {
131
+ queryParams: {},
132
+ queries: [],
133
+ keysGenerated: 0,
134
+ };
135
+ }
136
+
137
+ let currentArgIndex = argIndexStart;
138
+
139
+ const multiSelectQueryParams = Object.fromEntries(
140
+ filterValue.map(value => {
141
+ currentArgIndex += 1;
142
+ // the item-type-selector is returning the extensions with the suffix 'Type', so we remove it for the query
143
+ return [
144
+ generateArgKey(fieldKey, currentArgIndex),
145
+ String(value.endsWith('Type') ? value.slice(0, -4) : value),
146
+ ];
147
+ }),
148
+ );
149
+
150
+ return {
151
+ queryParams: multiSelectQueryParams,
152
+ queries: [
153
+ `(item.extension IN (${Object.keys(multiSelectQueryParams)
154
+ .map(argKey => `:${argKey}`)
155
+ .join(', ')}))`,
156
+ ],
157
+ keysGenerated: currentArgIndex - argIndexStart,
158
+ };
159
+ };
@@ -1,7 +1,7 @@
1
1
  import React, { useState } from 'react';
2
2
  import { useIntl } from 'react-intl';
3
3
 
4
- import { IconButton, SidePanel, Text } from '@box/blueprint-web';
4
+ import { IconButton, SidePanel, Text, useNotification } from '@box/blueprint-web';
5
5
  import { XMark } from '@box/blueprint-web-assets/icons/Fill/index';
6
6
  import { FileDefault } from '@box/blueprint-web-assets/icons/Line/index';
7
7
  import {
@@ -10,12 +10,13 @@ import {
10
10
  JSONPatchOperations,
11
11
  MetadataInstance,
12
12
  MetadataInstanceForm,
13
+ type MetadataTemplateField,
13
14
  } from '@box/metadata-editor';
14
15
 
15
16
  import type { Selection } from 'react-aria-components';
16
- import type { Collection } from '../../common/types/core';
17
+ import type { BoxItem, Collection } from '../../common/types/core';
17
18
  import type { MetadataTemplate } from '../../common/types/metadata';
18
- import { getTemplateInstance, useSelectedItemText } from './utils';
19
+ import { useTemplateInstance, useSelectedItemText } from './utils';
19
20
 
20
21
  import messages from '../common/messages';
21
22
 
@@ -23,17 +24,29 @@ import './MetadataSidePanel.scss';
23
24
 
24
25
  export interface MetadataSidePanelProps {
25
26
  currentCollection: Collection;
26
- onClose: () => void;
27
27
  metadataTemplate: MetadataTemplate;
28
+ onClose: () => void;
29
+ onUpdate: (
30
+ items: BoxItem[],
31
+ operations: JSONPatchOperations,
32
+ templateOldFields: MetadataTemplateField[],
33
+ templateNewFields: MetadataTemplateField[],
34
+ successCallback: () => void,
35
+ errorCallback: ErrorCallback,
36
+ ) => Promise<void>;
37
+ refreshCollection: () => void;
28
38
  selectedItemIds: Selection;
29
39
  }
30
40
 
31
41
  const MetadataSidePanel = ({
32
42
  currentCollection,
43
+ metadataTemplate,
33
44
  onClose,
45
+ onUpdate,
46
+ refreshCollection,
34
47
  selectedItemIds,
35
- metadataTemplate,
36
48
  }: MetadataSidePanelProps) => {
49
+ const { addNotification } = useNotification();
37
50
  const { formatMessage } = useIntl();
38
51
  const [isEditing, setIsEditing] = useState<boolean>(false);
39
52
  const [isUnsavedChangesModalOpen, setIsUnsavedChangesModalOpen] = useState<boolean>(false);
@@ -43,7 +56,7 @@ const MetadataSidePanel = ({
43
56
  selectedItemIds === 'all'
44
57
  ? currentCollection.items
45
58
  : currentCollection.items.filter(item => selectedItemIds.has(item.id));
46
- const templateInstance = getTemplateInstance(metadataTemplate, selectedItems);
59
+ const templateInstance = useTemplateInstance(metadataTemplate, selectedItems, isEditing);
47
60
 
48
61
  const handleMetadataInstanceEdit = () => {
49
62
  setIsEditing(true);
@@ -53,19 +66,47 @@ const MetadataSidePanel = ({
53
66
  setIsEditing(false);
54
67
  };
55
68
 
56
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
57
- const handleMetadataInstanceFormChange = (values: FormValues) => {
58
- // TODO: Implement on form change
59
- };
60
-
61
69
  const handleMetadataInstanceFormDiscardUnsavedChanges = () => {
62
70
  setIsUnsavedChangesModalOpen(false);
63
71
  setIsEditing(false);
64
72
  };
65
73
 
66
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
74
+ const handleUpdateMetadataSuccess = () => {
75
+ addNotification({
76
+ closeButtonAriaLabel: formatMessage(messages.close),
77
+ sensitivity: 'foreground',
78
+ styledText: formatMessage(messages.metadataUpdateSuccessNotification, {
79
+ numSelected: selectedItems.length,
80
+ }),
81
+ typeIconAriaLabel: formatMessage(messages.success),
82
+ variant: 'success',
83
+ });
84
+ setIsEditing(false);
85
+ refreshCollection();
86
+ };
87
+
88
+ const handleUpdateMetadataError = () => {
89
+ addNotification({
90
+ closeButtonAriaLabel: formatMessage(messages.close),
91
+ sensitivity: 'foreground',
92
+ styledText: formatMessage(messages.metadataUpdateErrorNotification),
93
+ typeIconAriaLabel: formatMessage(messages.error),
94
+ variant: 'error',
95
+ });
96
+ };
97
+
67
98
  const handleMetadataInstanceFormSubmit = async (values: FormValues, operations: JSONPatchOperations) => {
68
- // TODO: Implement onSave callback
99
+ const { fields: templateNewFields } = values.metadata;
100
+ const { fields: templateOldFields } = templateInstance;
101
+
102
+ await onUpdate(
103
+ selectedItems,
104
+ operations,
105
+ templateOldFields,
106
+ templateNewFields,
107
+ handleUpdateMetadataSuccess,
108
+ handleUpdateMetadataError,
109
+ );
69
110
  };
70
111
 
71
112
  return (
@@ -99,7 +140,7 @@ const MetadataSidePanel = ({
99
140
  isUnsavedChangesModalOpen={isUnsavedChangesModalOpen}
100
141
  selectedTemplateInstance={templateInstance}
101
142
  onCancel={handleMetadataInstanceFormCancel}
102
- onChange={handleMetadataInstanceFormChange}
143
+ onChange={null}
103
144
  onDelete={null}
104
145
  onDiscardUnsavedChanges={handleMetadataInstanceFormDiscardUnsavedChanges}
105
146
  onSubmit={handleMetadataInstanceFormSubmit}
@@ -1,18 +1,39 @@
1
1
  import * as React from 'react';
2
- import type { EnumType, FloatType, MetadataFormFieldValue, RangeType } from '@box/metadata-filter';
3
- import { MetadataView, type MetadataViewProps } from '@box/metadata-view';
2
+ import { useIntl } from 'react-intl';
3
+ import {
4
+ EnumType,
5
+ FloatType,
6
+ MetadataFormFieldValue,
7
+ MetadataTemplateFieldOption,
8
+ RangeType,
9
+ } from '@box/metadata-filter';
10
+ import {
11
+ MetadataView,
12
+ type FilterValues,
13
+ type MetadataViewProps,
14
+ type MetadataFieldType,
15
+ type Column,
16
+ } from '@box/metadata-view';
17
+ import { type Key } from '@react-types/shared';
18
+ import cloneDeep from 'lodash/cloneDeep';
4
19
 
20
+ import { SortDescriptor } from 'react-aria-components';
21
+ import { FIELD_ITEM_NAME } from '../../constants';
5
22
  import type { Collection } from '../../common/types/core';
6
- import type { MetadataTemplate } from '../../common/types/metadata';
23
+ import type { MetadataTemplate, MetadataTemplateField } from '../../common/types/metadata';
24
+
25
+ import messages from '../common/messages';
7
26
 
8
27
  // Public-friendly version of MetadataFormFieldValue from @box/metadata-filter
9
28
  // (string[] for enum type, range/float objects stay the same)
10
29
  type EnumToStringArray<T> = T extends EnumType ? string[] : T;
11
30
  type ExternalMetadataFormFieldValue = EnumToStringArray<MetadataFormFieldValue>;
12
31
 
13
- type ExternalFilterValues = Record<
32
+ export type ExternalFilterValues = Record<
14
33
  string,
15
34
  {
35
+ options?: FilterValues[string]['options'] | MetadataTemplateFieldOption[];
36
+ fieldType: FilterValues[string]['fieldType'] | MetadataFieldType;
16
37
  value: ExternalMetadataFormFieldValue;
17
38
  }
18
39
  >;
@@ -25,6 +46,23 @@ type ActionBarProps = Omit<
25
46
  onFilterSubmit?: (filterValues: ExternalFilterValues) => void;
26
47
  };
27
48
 
49
+ const ITEM_FILTER_NAME = 'item_name';
50
+
51
+ /**
52
+ * Helper function to trim metadataFieldNamePrefix from column names
53
+ * For example: 'metadata.enterprise_1515946.mdViewTemplate1.industry' -> 'industry'
54
+ */
55
+ function trimMetadataFieldPrefix(column: string): string {
56
+ // Check if the column starts with 'metadata.' and contains at least 2 dots
57
+ if (column.startsWith('metadata.') && column.split('.').length >= 3) {
58
+ // Split by dots and take everything after the first 3 parts
59
+ // metadata.enterprise_1515946.mdViewTemplate1.industry -> industry
60
+ const parts = column.split('.');
61
+ return parts.slice(3).join('.');
62
+ }
63
+ return column;
64
+ }
65
+
28
66
  function transformInitialFilterValuesToInternal(
29
67
  publicValues?: ExternalFilterValues,
30
68
  ): Record<string, { value: MetadataFormFieldValue }> | undefined {
@@ -39,22 +77,38 @@ function transformInitialFilterValuesToInternal(
39
77
  );
40
78
  }
41
79
 
42
- function transformInternalFieldsToPublic(
43
- fields: Record<string, { value: MetadataFormFieldValue }>,
44
- ): ExternalFilterValues {
45
- return Object.entries(fields).reduce<ExternalFilterValues>((acc, [key, { value }]) => {
46
- acc[key] =
80
+ export function convertFilterValuesToExternal(fields: FilterValues): ExternalFilterValues {
81
+ return Object.entries(fields).reduce<ExternalFilterValues>((acc, [key, field]) => {
82
+ const { value, options, fieldType } = field;
83
+
84
+ // Transform the value based on its type
85
+ const transformedValue: ExternalMetadataFormFieldValue =
47
86
  'enum' in value && Array.isArray(value.enum)
48
- ? { value: value.enum }
49
- : { value: value as RangeType | FloatType };
87
+ ? value.enum // Convert enum type to string array
88
+ : (value as RangeType | FloatType); // Keep range/float objects as-is
89
+
90
+ acc[key === ITEM_FILTER_NAME ? FIELD_ITEM_NAME : key] = {
91
+ options,
92
+ fieldType,
93
+ value: transformedValue,
94
+ };
95
+
50
96
  return acc;
51
97
  }, {});
52
98
  }
53
99
 
100
+ // Internal helper function for component use
101
+ function transformInternalFieldsToPublic(fields: FilterValues): ExternalFilterValues {
102
+ return convertFilterValuesToExternal(fields);
103
+ }
104
+
54
105
  export interface MetadataViewContainerProps extends Omit<MetadataViewProps, 'items' | 'actionBarProps'> {
55
106
  actionBarProps?: ActionBarProps;
56
107
  currentCollection: Collection;
57
108
  metadataTemplate: MetadataTemplate;
109
+ onMetadataFilter: (fields: ExternalFilterValues) => void;
110
+ /* Internally controlled onSortChange prop for the MetadataView component. */
111
+ onSortChange?: (sortBy: Key, sortDirection: string) => void;
58
112
  }
59
113
 
60
114
  const MetadataViewContainer = ({
@@ -62,19 +116,65 @@ const MetadataViewContainer = ({
62
116
  columns,
63
117
  currentCollection,
64
118
  metadataTemplate,
119
+ onMetadataFilter,
120
+ onSortChange: onSortChangeInternal,
121
+ tableProps,
65
122
  ...rest
66
123
  }: MetadataViewContainerProps) => {
124
+ const { formatMessage } = useIntl();
67
125
  const { items = [] } = currentCollection;
68
- const { initialFilterValues: initialFilterValuesProp, onFilterSubmit: onFilterSubmitProp } = actionBarProps ?? {};
126
+ const { initialFilterValues: initialFilterValuesProp, onFilterSubmit } = actionBarProps ?? {};
127
+
128
+ const newColumns = React.useMemo(() => {
129
+ let clonedColumns = cloneDeep(columns);
69
130
 
70
- const filterGroups = React.useMemo(
71
- () => [
131
+ const hasItemNameField = clonedColumns.some((col: Column) => col.id === FIELD_ITEM_NAME);
132
+
133
+ if (!hasItemNameField) {
134
+ clonedColumns = [
135
+ {
136
+ allowsSorting: true,
137
+ id: FIELD_ITEM_NAME,
138
+ isItemMetadata: true,
139
+ isRowHeader: true,
140
+ minWidth: 250,
141
+ maxWidth: 250,
142
+ textValue: formatMessage(messages.name),
143
+ type: 'string',
144
+ },
145
+ ...clonedColumns,
146
+ ];
147
+ }
148
+
149
+ return clonedColumns;
150
+ }, [columns, formatMessage]);
151
+
152
+ const filterGroups = React.useMemo(() => {
153
+ const clonedTemplate = cloneDeep(metadataTemplate);
154
+ let fields = clonedTemplate?.fields || [];
155
+
156
+ // Check if item_name field already exists to avoid duplicates
157
+ const hasItemNameField = fields.some((field: MetadataTemplateField) => field.key === ITEM_FILTER_NAME);
158
+
159
+ if (!hasItemNameField) {
160
+ fields = [
161
+ {
162
+ key: ITEM_FILTER_NAME,
163
+ displayName: formatMessage(messages.name),
164
+ type: 'string',
165
+ shouldRenderChip: true,
166
+ },
167
+ ...fields,
168
+ ];
169
+ }
170
+
171
+ return [
72
172
  {
73
173
  toggleable: true,
74
174
  filters:
75
- metadataTemplate?.fields?.map(field => {
175
+ fields?.map(field => {
76
176
  return {
77
- id: `${field.key}-filter`,
177
+ id: field.key,
78
178
  name: field.displayName,
79
179
  fieldType: field.type,
80
180
  options: field.options?.map(({ key }) => key) || [],
@@ -82,36 +182,71 @@ const MetadataViewContainer = ({
82
182
  };
83
183
  }) || [],
84
184
  },
85
- ],
86
- [metadataTemplate],
87
- );
185
+ ];
186
+ }, [formatMessage, metadataTemplate]);
88
187
 
89
- // Transform initial filter values to internal field format
90
188
  const initialFilterValues = React.useMemo(
91
189
  () => transformInitialFilterValuesToInternal(initialFilterValuesProp),
92
190
  [initialFilterValuesProp],
93
191
  );
94
192
 
95
- // Transform field values to public-friendly format
96
- const onFilterSubmit = React.useCallback(
97
- (fields: Record<string, { value: MetadataFormFieldValue }>) => {
98
- if (!onFilterSubmitProp) return;
193
+ const handleFilterSubmit = React.useCallback(
194
+ (fields: FilterValues) => {
99
195
  const transformed = transformInternalFieldsToPublic(fields);
100
- onFilterSubmitProp(transformed);
196
+ onMetadataFilter(transformed);
197
+ if (onFilterSubmit) {
198
+ onFilterSubmit(transformed);
199
+ }
101
200
  },
102
- [onFilterSubmitProp],
201
+ [onFilterSubmit, onMetadataFilter],
103
202
  );
104
203
 
105
204
  const transformedActionBarProps = React.useMemo(() => {
106
205
  return {
107
206
  ...actionBarProps,
108
207
  initialFilterValues,
109
- onFilterSubmit,
208
+ onFilterSubmit: handleFilterSubmit,
110
209
  filterGroups,
111
210
  };
112
- }, [actionBarProps, initialFilterValues, onFilterSubmit, filterGroups]);
211
+ }, [actionBarProps, initialFilterValues, handleFilterSubmit, filterGroups]);
212
+
213
+ // Create a wrapper function that calls both. The wrapper function should follow the signature of onSortChange from RAC
214
+ const handleSortChange = React.useCallback(
215
+ ({ column, direction }: SortDescriptor) => {
216
+ // Call the internal onSortChange first
217
+ // API accepts asc/desc "https://developer.box.com/reference/post-metadata-queries-execute-read/"
218
+ if (onSortChangeInternal) {
219
+ const trimmedColumn = trimMetadataFieldPrefix(String(column));
220
+ onSortChangeInternal(trimmedColumn, direction === 'ascending' ? 'ASC' : 'DESC');
221
+ }
222
+ const onSortChangeExternal = tableProps?.onSortChange;
223
+ // Then call the original customer-provided onSortChange if it exists
224
+ // Accepts "ascending" / "descending" (https://react-spectrum.adobe.com/react-aria/Table.html)
225
+ if (onSortChangeExternal) {
226
+ onSortChangeExternal({
227
+ column,
228
+ direction,
229
+ });
230
+ }
231
+ },
232
+ [onSortChangeInternal, tableProps],
233
+ );
234
+
235
+ // Create new tableProps with our wrapper function
236
+ const newTableProps = {
237
+ ...tableProps,
238
+ onSortChange: handleSortChange,
239
+ };
113
240
 
114
- return <MetadataView actionBarProps={transformedActionBarProps} columns={columns} items={items} {...rest} />;
241
+ return (
242
+ <MetadataView
243
+ actionBarProps={transformedActionBarProps}
244
+ columns={newColumns}
245
+ items={items}
246
+ tableProps={newTableProps}
247
+ {...rest}
248
+ />
249
+ );
115
250
  };
116
251
 
117
252
  export default MetadataViewContainer;
@@ -30,6 +30,7 @@ const mockProps: ContentProps = {
30
30
  onItemRename: jest.fn(),
31
31
  onItemSelect: jest.fn(),
32
32
  onItemShare: jest.fn(),
33
+ onMetadataFilter: jest.fn(),
33
34
  onMetadataUpdate: jest.fn(),
34
35
  onSortChange: jest.fn(),
35
36
  portalElement: null,
@@ -454,7 +454,7 @@ describe('elements/content-explorer/ContentExplorer', () => {
454
454
  textValue: 'Name',
455
455
  id: 'name',
456
456
  type: 'string' as const,
457
- allowSorting: true,
457
+ allowsSorting: true,
458
458
  minWidth: 150,
459
459
  maxWidth: 150,
460
460
  },
@@ -462,16 +462,15 @@ describe('elements/content-explorer/ContentExplorer', () => {
462
462
  textValue: field.displayName,
463
463
  id: `${metadataFieldNamePrefix}.${field.key}`,
464
464
  type: field.type as MetadataFieldType,
465
- allowSorting: true,
465
+ allowsSorting: true,
466
466
  minWidth: 150,
467
467
  maxWidth: 150,
468
468
  })),
469
469
  ];
470
470
  const defaultView = 'metadata';
471
- const metadataViewV2ElementProps = {
471
+ const metadataViewV2ElementProps: Partial<ContentExplorerProps> = {
472
472
  metadataViewProps: {
473
473
  columns,
474
- metadataTemplate: mockSchema,
475
474
  tableProps: {
476
475
  isSelectAllEnabled: true,
477
476
  },
@@ -496,9 +495,7 @@ describe('elements/content-explorer/ContentExplorer', () => {
496
495
  expect(screen.queryByRole('button', { name: 'Switch to Grid View' })).toBeInTheDocument();
497
496
  });
498
497
 
499
- await waitFor(() => {
500
- expect(screen.getByRole('row', { name: /Child 2/i })).toBeInTheDocument();
501
- });
498
+ expect(screen.getByRole('row', { name: /Child 2/i })).toBeInTheDocument();
502
499
 
503
500
  const selectAllCheckbox = screen.getByLabelText('Select all');
504
501
  await userEvent.click(selectAllCheckbox);
@@ -506,6 +503,40 @@ describe('elements/content-explorer/ContentExplorer', () => {
506
503
  expect(screen.getByRole('button', { name: 'Metadata' })).toBeInTheDocument();
507
504
  });
508
505
 
506
+ test('should call both internal and user onSortChange callbacks when sorting by a metadata field', async () => {
507
+ const mockOnSortChangeInternal = jest.fn();
508
+ const mockOnSortChangeExternal = jest.fn();
509
+
510
+ renderComponent({
511
+ ...metadataViewV2ElementProps,
512
+ metadataViewProps: {
513
+ ...metadataViewV2ElementProps.metadataViewProps,
514
+ onSortChange: mockOnSortChangeInternal, // Internal callback - receives trimmed column name
515
+ tableProps: {
516
+ ...metadataViewV2ElementProps.metadataViewProps.tableProps,
517
+ onSortChange: mockOnSortChangeExternal, // User callback - receives full column ID
518
+ },
519
+ },
520
+ });
521
+
522
+ const industryHeader = await screen.findByRole('columnheader', { name: 'Industry' });
523
+ expect(industryHeader).toBeInTheDocument();
524
+
525
+ const firstRow = await screen.findByRole('row', { name: /Child 2/i });
526
+ expect(firstRow).toBeInTheDocument();
527
+
528
+ await userEvent.click(industryHeader);
529
+
530
+ // Internal callback gets trimmed version for API calls
531
+ expect(mockOnSortChangeInternal).toHaveBeenCalledWith('industry', 'ASC');
532
+
533
+ // User callback gets full column ID with direction
534
+ expect(mockOnSortChangeExternal).toHaveBeenCalledWith({
535
+ column: 'metadata.enterprise_0.templateName.industry',
536
+ direction: 'ascending',
537
+ });
538
+ });
539
+
509
540
  test('should call onClick when bulk item action is clicked', async () => {
510
541
  let mockOnClickArg;
511
542
  const mockOnClick = jest.fn(arg => {