box-ui-elements 24.0.0-beta.3 → 24.0.0-beta.5

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 (159) hide show
  1. package/dist/explorer.js +1 -1
  2. package/dist/openwith.js +1 -1
  3. package/dist/picker.js +1 -1
  4. package/dist/preview.js +1 -1
  5. package/dist/sharing.js +1 -1
  6. package/dist/sidebar.js +1 -1
  7. package/dist/uploader.js +1 -1
  8. package/es/api/Metadata.js +98 -13
  9. package/es/api/Metadata.js.flow +110 -12
  10. package/es/api/Metadata.js.map +1 -1
  11. package/es/elements/common/messages.js +16 -0
  12. package/es/elements/common/messages.js.flow +25 -0
  13. package/es/elements/common/messages.js.map +1 -1
  14. package/es/elements/common/withBlueprintModernization.js +16 -0
  15. package/es/elements/common/withBlueprintModernization.js.map +1 -0
  16. package/es/elements/content-explorer/Content.js +2 -1
  17. package/es/elements/content-explorer/Content.js.map +1 -1
  18. package/es/elements/content-explorer/ContentExplorer.js +21 -6
  19. package/es/elements/content-explorer/ContentExplorer.js.map +1 -1
  20. package/es/elements/content-explorer/MetadataQueryAPIHelper.js +61 -4
  21. package/es/elements/content-explorer/MetadataQueryAPIHelper.js.map +1 -1
  22. package/es/elements/content-explorer/MetadataSidePanel.js +40 -14
  23. package/es/elements/content-explorer/MetadataSidePanel.js.map +1 -1
  24. package/es/elements/content-explorer/MetadataViewContainer.js +55 -4
  25. package/es/elements/content-explorer/MetadataViewContainer.js.map +1 -1
  26. package/es/elements/content-explorer/stories/tests/ContentExplorer-visual.stories.js +5 -0
  27. package/es/elements/content-explorer/stories/tests/ContentExplorer-visual.stories.js.flow +6 -0
  28. package/es/elements/content-explorer/stories/tests/ContentExplorer-visual.stories.js.map +1 -1
  29. package/es/elements/content-explorer/stories/tests/MetadataView-visual.stories.js +61 -13
  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/elements/content-picker/ContentPicker.js +4 -1
  34. package/es/elements/content-picker/ContentPicker.js.flow +4 -1
  35. package/es/elements/content-picker/ContentPicker.js.map +1 -1
  36. package/es/elements/content-picker/stories/tests/ContentPicker-visual.stories.js +5 -0
  37. package/es/elements/content-picker/stories/tests/ContentPicker-visual.stories.js.flow +6 -0
  38. package/es/elements/content-picker/stories/tests/ContentPicker-visual.stories.js.map +1 -1
  39. package/es/elements/content-preview/ContentPreview.js +3 -1
  40. package/es/elements/content-preview/ContentPreview.js.flow +3 -0
  41. package/es/elements/content-preview/ContentPreview.js.map +1 -1
  42. package/es/elements/content-preview/stories/tests/ContentPreview-visual.stories.js +5 -0
  43. package/es/elements/content-preview/stories/tests/ContentPreview-visual.stories.js.flow +7 -1
  44. package/es/elements/content-preview/stories/tests/ContentPreview-visual.stories.js.map +1 -1
  45. package/es/elements/content-sharing/ContentSharing.js +4 -1
  46. package/es/elements/content-sharing/ContentSharing.js.flow +4 -1
  47. package/es/elements/content-sharing/ContentSharing.js.map +1 -1
  48. package/es/elements/content-sidebar/ContentSidebar.js +3 -1
  49. package/es/elements/content-sidebar/ContentSidebar.js.flow +3 -0
  50. package/es/elements/content-sidebar/ContentSidebar.js.map +1 -1
  51. package/es/elements/content-sidebar/stories/tests/ContentSidebar-visual.stories.js +5 -0
  52. package/es/elements/content-sidebar/stories/tests/ContentSidebar-visual.stories.js.map +1 -1
  53. package/es/elements/content-uploader/ContentUploader.js +3 -1
  54. package/es/elements/content-uploader/ContentUploader.js.map +1 -1
  55. package/es/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js +5 -0
  56. package/es/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js.flow +6 -0
  57. package/es/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js.map +1 -1
  58. package/es/features/classification/applied-by-ai-classification-reason/AppliedByAiClassificationReason.js +51 -0
  59. package/es/features/classification/applied-by-ai-classification-reason/AppliedByAiClassificationReason.js.map +1 -0
  60. package/es/features/classification/applied-by-ai-classification-reason/AppliedByAiClassificationReason.scss +29 -0
  61. package/es/features/classification/applied-by-ai-classification-reason/messages.js +13 -0
  62. package/es/features/classification/applied-by-ai-classification-reason/messages.js.map +1 -0
  63. package/es/features/classification/types.js +2 -0
  64. package/es/features/classification/types.js.map +1 -0
  65. package/es/src/elements/common/__tests__/withBlueprintModernization.test.d.ts +1 -0
  66. package/es/src/elements/common/withBlueprintModernization.d.ts +3 -0
  67. package/es/src/elements/content-explorer/ContentExplorer.d.ts +11 -3
  68. package/es/src/elements/content-explorer/MetadataQueryAPIHelper.d.ts +11 -1
  69. package/es/src/elements/content-explorer/MetadataSidePanel.d.ts +6 -3
  70. package/es/src/elements/content-explorer/MetadataViewContainer.d.ts +3 -1
  71. package/es/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.d.ts +1 -0
  72. package/es/src/elements/content-explorer/utils.d.ts +9 -3
  73. package/es/src/elements/content-sidebar/stories/tests/ContentSidebar-visual.stories.d.ts +5 -0
  74. package/es/src/features/classification/applied-by-ai-classification-reason/AppliedByAiClassificationReason.d.ts +6 -0
  75. package/es/src/features/classification/applied-by-ai-classification-reason/__tests__/AppliedByAiClassificationReason.test.d.ts +1 -0
  76. package/es/src/features/classification/applied-by-ai-classification-reason/messages.d.ts +13 -0
  77. package/es/src/features/classification/types.d.ts +6 -0
  78. package/i18n/bn-IN.js +8 -2
  79. package/i18n/bn-IN.properties +6 -2
  80. package/i18n/da-DK.js +8 -2
  81. package/i18n/da-DK.properties +6 -2
  82. package/i18n/de-DE.js +8 -2
  83. package/i18n/de-DE.properties +6 -2
  84. package/i18n/en-AU.js +6 -0
  85. package/i18n/en-AU.properties +4 -0
  86. package/i18n/en-CA.js +6 -0
  87. package/i18n/en-CA.properties +4 -0
  88. package/i18n/en-GB.js +6 -0
  89. package/i18n/en-GB.properties +4 -0
  90. package/i18n/en-US.js +6 -0
  91. package/i18n/en-US.properties +12 -0
  92. package/i18n/en-x-pseudo.js +6 -0
  93. package/i18n/es-419.js +8 -2
  94. package/i18n/es-419.properties +6 -2
  95. package/i18n/es-ES.js +8 -2
  96. package/i18n/es-ES.properties +6 -2
  97. package/i18n/fi-FI.js +8 -2
  98. package/i18n/fi-FI.properties +6 -2
  99. package/i18n/fr-CA.js +8 -2
  100. package/i18n/fr-CA.properties +6 -2
  101. package/i18n/fr-FR.js +8 -2
  102. package/i18n/fr-FR.properties +6 -2
  103. package/i18n/hi-IN.js +8 -2
  104. package/i18n/hi-IN.properties +6 -2
  105. package/i18n/it-IT.js +8 -2
  106. package/i18n/it-IT.properties +6 -2
  107. package/i18n/ja-JP.js +8 -2
  108. package/i18n/ja-JP.properties +6 -2
  109. package/i18n/ko-KR.js +8 -2
  110. package/i18n/ko-KR.properties +6 -2
  111. package/i18n/nb-NO.js +8 -2
  112. package/i18n/nb-NO.properties +6 -2
  113. package/i18n/nl-NL.js +8 -2
  114. package/i18n/nl-NL.properties +6 -2
  115. package/i18n/pl-PL.js +8 -2
  116. package/i18n/pl-PL.properties +6 -2
  117. package/i18n/pt-BR.js +8 -2
  118. package/i18n/pt-BR.properties +6 -2
  119. package/i18n/ru-RU.js +8 -2
  120. package/i18n/ru-RU.properties +6 -2
  121. package/i18n/sv-SE.js +8 -2
  122. package/i18n/sv-SE.properties +6 -2
  123. package/i18n/tr-TR.js +8 -2
  124. package/i18n/tr-TR.properties +6 -2
  125. package/i18n/zh-CN.js +8 -2
  126. package/i18n/zh-CN.properties +6 -2
  127. package/i18n/zh-TW.js +8 -2
  128. package/i18n/zh-TW.properties +6 -2
  129. package/package.json +1 -1
  130. package/src/api/Metadata.js +110 -12
  131. package/src/api/__tests__/Metadata.test.js +120 -0
  132. package/src/elements/common/__tests__/withBlueprintModernization.test.tsx +91 -0
  133. package/src/elements/common/messages.js +25 -0
  134. package/src/elements/common/withBlueprintModernization.tsx +24 -0
  135. package/src/elements/content-explorer/Content.tsx +1 -0
  136. package/src/elements/content-explorer/ContentExplorer.tsx +224 -182
  137. package/src/elements/content-explorer/MetadataQueryAPIHelper.ts +89 -4
  138. package/src/elements/content-explorer/MetadataSidePanel.tsx +55 -14
  139. package/src/elements/content-explorer/MetadataViewContainer.tsx +61 -1
  140. package/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +36 -2
  141. package/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts +8 -5
  142. package/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx +145 -3
  143. package/src/elements/content-explorer/stories/tests/ContentExplorer-visual.stories.js +6 -0
  144. package/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +54 -8
  145. package/src/elements/content-explorer/utils.ts +150 -13
  146. package/src/elements/content-picker/ContentPicker.js +4 -1
  147. package/src/elements/content-picker/stories/tests/ContentPicker-visual.stories.js +6 -0
  148. package/src/elements/content-preview/ContentPreview.js +3 -0
  149. package/src/elements/content-preview/stories/tests/ContentPreview-visual.stories.js +7 -1
  150. package/src/elements/content-sharing/ContentSharing.js +4 -1
  151. package/src/elements/content-sidebar/ContentSidebar.js +3 -0
  152. package/src/elements/content-sidebar/stories/tests/ContentSidebar-visual.stories.tsx +6 -0
  153. package/src/elements/content-uploader/ContentUploader.tsx +3 -1
  154. package/src/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js +6 -0
  155. package/src/features/classification/applied-by-ai-classification-reason/AppliedByAiClassificationReason.scss +29 -0
  156. package/src/features/classification/applied-by-ai-classification-reason/AppliedByAiClassificationReason.tsx +55 -0
  157. package/src/features/classification/applied-by-ai-classification-reason/__tests__/AppliedByAiClassificationReason.test.tsx +105 -0
  158. package/src/features/classification/applied-by-ai-classification-reason/messages.ts +18 -0
  159. package/src/features/classification/types.ts +7 -0
@@ -3,8 +3,10 @@ import find from 'lodash/find';
3
3
  import getProp from 'lodash/get';
4
4
  import includes from 'lodash/includes';
5
5
  import isArray from 'lodash/isArray';
6
- import isNil from 'lodash/isNil';
6
+ import type { MetadataTemplateField } from '@box/metadata-editor';
7
+ import type { MetadataFieldType } from '@box/metadata-view';
7
8
  import API from '../../api';
9
+ import { areFieldValuesEqual, isEmptyValue, isMultiValuesField } from './utils';
8
10
 
9
11
  import {
10
12
  JSON_PATCH_OP_ADD,
@@ -14,7 +16,7 @@ import {
14
16
  METADATA_FIELD_TYPE_ENUM,
15
17
  METADATA_FIELD_TYPE_MULTISELECT,
16
18
  } from '../../common/constants';
17
- import { FIELD_NAME, FIELD_METADATA, FIELD_EXTENSION } from '../../constants';
19
+ import { FIELD_NAME, FIELD_METADATA, FIELD_EXTENSION, FIELD_PERMISSIONS } from '../../constants';
18
20
 
19
21
  import type { MetadataQuery as MetadataQueryType, MetadataQueryResponseData } from '../../common/types/metadataQueries';
20
22
  import type {
@@ -57,13 +59,18 @@ export default class MetadataQueryAPIHelper {
57
59
  oldValue: MetadataFieldValue | null,
58
60
  newValue: MetadataFieldValue | null,
59
61
  ): JSONPatchOperations => {
62
+ // check if two values are the same, return empty operations if so
63
+ if (areFieldValuesEqual(oldValue, newValue)) {
64
+ return [];
65
+ }
66
+
60
67
  let operation = JSON_PATCH_OP_REPLACE;
61
68
 
62
- if (isNil(oldValue) && newValue) {
69
+ if (isEmptyValue(oldValue) && !isEmptyValue(newValue)) {
63
70
  operation = JSON_PATCH_OP_ADD;
64
71
  }
65
72
 
66
- if (oldValue && isNil(newValue)) {
73
+ if (!isEmptyValue(oldValue) && isEmptyValue(newValue)) {
67
74
  operation = JSON_PATCH_OP_REMOVE;
68
75
  }
69
76
 
@@ -170,6 +177,51 @@ export default class MetadataQueryAPIHelper {
170
177
  return this.api.getMetadataAPI(true).getSchemaByTemplateKey(this.templateKey);
171
178
  };
172
179
 
180
+ /**
181
+ * Generate operations for all fields update in the metadata sidepanel
182
+ *
183
+ * @private
184
+ * @return {JSONPatchOperations}
185
+ */
186
+ generateOperations = (
187
+ item: BoxItem,
188
+ templateOldFields: MetadataTemplateField[],
189
+ templateNewFields: MetadataTemplateField[],
190
+ ): JSONPatchOperations => {
191
+ const { scope, templateKey } = this.metadataTemplate;
192
+ const itemFields = item.metadata[scope][templateKey];
193
+ const operations = templateNewFields.flatMap(newField => {
194
+ let newFieldValue = newField.value;
195
+ const { key, type } = newField;
196
+ // when retrieve value from float type field, it gives a string instead
197
+ if (type === 'float' && newFieldValue !== '') {
198
+ newFieldValue = Number(newFieldValue);
199
+ }
200
+ const oldField = templateOldFields.find(f => f.key === key);
201
+ const oldFieldValue = oldField.value;
202
+
203
+ /*
204
+ Generate operations array based on all the fields' orignal value and the incoming updated value.
205
+
206
+ Edge Case:
207
+ If there are multiple items shared different value for enum or multi-select field, the form will
208
+ return 'Multiple values' as the value. In this case, it needs to generate operation based on the
209
+ actual item's field value.
210
+ */
211
+ const shouldUseItemFieldValue =
212
+ isMultiValuesField(type as MetadataFieldType, oldFieldValue) &&
213
+ !isMultiValuesField(type as MetadataFieldType, newFieldValue);
214
+
215
+ return this.createJSONPatchOperations(
216
+ key,
217
+ shouldUseItemFieldValue ? itemFields[key] : oldFieldValue,
218
+ newFieldValue,
219
+ );
220
+ });
221
+
222
+ return operations;
223
+ };
224
+
173
225
  queryMetadata = (): Promise<MetadataQueryResponseData> => {
174
226
  return new Promise((resolve, reject) => {
175
227
  this.api.getMetadataQueryAPI().queryMetadata(this.metadataQuery, resolve, reject, { forceFetch: true });
@@ -205,6 +257,34 @@ export default class MetadataQueryAPIHelper {
205
257
  .updateMetadata(file, this.metadataTemplate, operations, successCallback, errorCallback);
206
258
  };
207
259
 
260
+ updateMetadataWithOperations = (
261
+ item: BoxItem,
262
+ operations: JSONPatchOperations,
263
+ successCallback: () => void,
264
+ errorCallback: ErrorCallback,
265
+ ): Promise<void> => {
266
+ return this.api
267
+ .getMetadataAPI(true)
268
+ .updateMetadata(item, this.metadataTemplate, operations, successCallback, errorCallback);
269
+ };
270
+
271
+ bulkUpdateMetadata = (
272
+ items: BoxItem[],
273
+ templateOldFields: MetadataTemplateField[],
274
+ templateNewFields: MetadataTemplateField[],
275
+ successCallback: () => void,
276
+ errorCallback: ErrorCallback,
277
+ ): Promise<void> => {
278
+ const operations: JSONPatchOperations = [];
279
+ items.forEach(item => {
280
+ const operation = this.generateOperations(item, templateOldFields, templateNewFields);
281
+ operations.push(operation);
282
+ });
283
+ return this.api
284
+ .getMetadataAPI(true)
285
+ .bulkUpdateMetadata(items, this.metadataTemplate, operations, successCallback, errorCallback);
286
+ };
287
+
208
288
  /**
209
289
  * Verify that the metadata query has required fields and update it if necessary
210
290
  * For a file item, default fields included in the response are "type", "id", "etag"
@@ -225,6 +305,11 @@ export default class MetadataQueryAPIHelper {
225
305
  clonedFields.push(FIELD_EXTENSION);
226
306
  }
227
307
 
308
+ // This field is necessary to check if the user has permission to update metadata
309
+ if (!clonedFields.includes(FIELD_PERMISSIONS)) {
310
+ clonedFields.push(FIELD_PERMISSIONS);
311
+ }
312
+
228
313
  clonedQuery.fields = clonedFields;
229
314
 
230
315
  return clonedQuery;
@@ -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,7 +1,9 @@
1
1
  import * as React from 'react';
2
2
  import type { EnumType, FloatType, MetadataFormFieldValue, RangeType } from '@box/metadata-filter';
3
3
  import { MetadataView, type MetadataViewProps } from '@box/metadata-view';
4
+ import { type Key } from '@react-types/shared';
4
5
 
6
+ import { SortDescriptor } from 'react-aria-components';
5
7
  import type { Collection } from '../../common/types/core';
6
8
  import type { MetadataTemplate } from '../../common/types/metadata';
7
9
 
@@ -25,6 +27,21 @@ type ActionBarProps = Omit<
25
27
  onFilterSubmit?: (filterValues: ExternalFilterValues) => void;
26
28
  };
27
29
 
30
+ /**
31
+ * Helper function to trim metadataFieldNamePrefix from column names
32
+ * For example: 'metadata.enterprise_1515946.mdViewTemplate1.industry' -> 'industry'
33
+ */
34
+ function trimMetadataFieldPrefix(column: string): string {
35
+ // Check if the column starts with 'metadata.' and contains at least 2 dots
36
+ if (column.startsWith('metadata.') && column.split('.').length >= 3) {
37
+ // Split by dots and take everything after the first 3 parts
38
+ // metadata.enterprise_1515946.mdViewTemplate1.industry -> industry
39
+ const parts = column.split('.');
40
+ return parts.slice(3).join('.');
41
+ }
42
+ return column;
43
+ }
44
+
28
45
  function transformInitialFilterValuesToInternal(
29
46
  publicValues?: ExternalFilterValues,
30
47
  ): Record<string, { value: MetadataFormFieldValue }> | undefined {
@@ -55,6 +72,8 @@ export interface MetadataViewContainerProps extends Omit<MetadataViewProps, 'ite
55
72
  actionBarProps?: ActionBarProps;
56
73
  currentCollection: Collection;
57
74
  metadataTemplate: MetadataTemplate;
75
+ /* Internally controlled onSortChange prop for the MetadataView component. */
76
+ onSortChange?: (sortBy: Key, sortDirection: string) => void;
58
77
  }
59
78
 
60
79
  const MetadataViewContainer = ({
@@ -62,6 +81,7 @@ const MetadataViewContainer = ({
62
81
  columns,
63
82
  currentCollection,
64
83
  metadataTemplate,
84
+ onSortChange: onSortChangeInternal,
65
85
  ...rest
66
86
  }: MetadataViewContainerProps) => {
67
87
  const { items = [] } = currentCollection;
@@ -111,7 +131,47 @@ const MetadataViewContainer = ({
111
131
  };
112
132
  }, [actionBarProps, initialFilterValues, onFilterSubmit, filterGroups]);
113
133
 
114
- return <MetadataView actionBarProps={transformedActionBarProps} columns={columns} items={items} {...rest} />;
134
+ // Extract the original tableProps.onSortChange from rest
135
+ const { tableProps, ...otherRest } = rest;
136
+ const onSortChangeExternal = tableProps?.onSortChange;
137
+
138
+ // Create a wrapper function that calls both. The wrapper function should follow the signature of onSortChange from RAC
139
+ const handleSortChange = React.useCallback(
140
+ ({ column, direction }: SortDescriptor) => {
141
+ // Call the internal onSortChange first
142
+ // API accepts asc/desc "https://developer.box.com/reference/post-metadata-queries-execute-read/"
143
+ if (onSortChangeInternal) {
144
+ const trimmedColumn = trimMetadataFieldPrefix(String(column));
145
+ onSortChangeInternal(trimmedColumn, direction === 'ascending' ? 'ASC' : 'DESC');
146
+ }
147
+
148
+ // Then call the original customer-provided onSortChange if it exists
149
+ // Accepts "ascending" / "descending" (https://react-spectrum.adobe.com/react-aria/Table.html)
150
+ if (onSortChangeExternal) {
151
+ onSortChangeExternal({
152
+ column,
153
+ direction,
154
+ });
155
+ }
156
+ },
157
+ [onSortChangeInternal, onSortChangeExternal],
158
+ );
159
+
160
+ // Create new tableProps with our wrapper function
161
+ const newTableProps = {
162
+ ...tableProps,
163
+ onSortChange: handleSortChange,
164
+ };
165
+
166
+ return (
167
+ <MetadataView
168
+ actionBarProps={transformedActionBarProps}
169
+ columns={columns}
170
+ items={items}
171
+ tableProps={newTableProps}
172
+ {...otherRest}
173
+ />
174
+ );
115
175
  };
116
176
 
117
177
  export default MetadataViewContainer;
@@ -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,7 +462,7 @@ 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
  })),
@@ -506,6 +506,40 @@ describe('elements/content-explorer/ContentExplorer', () => {
506
506
  expect(screen.getByRole('button', { name: 'Metadata' })).toBeInTheDocument();
507
507
  });
508
508
 
509
+ test('should call both internal and user onSortChange callbacks when sorting by a metadata field', async () => {
510
+ const mockOnSortChangeInternal = jest.fn();
511
+ const mockOnSortChangeExternal = jest.fn();
512
+
513
+ renderComponent({
514
+ ...metadataViewV2ElementProps,
515
+ metadataViewProps: {
516
+ ...metadataViewV2ElementProps.metadataViewProps,
517
+ onSortChange: mockOnSortChangeInternal, // Internal callback - receives trimmed column name
518
+ tableProps: {
519
+ ...metadataViewV2ElementProps.metadataViewProps.tableProps,
520
+ onSortChange: mockOnSortChangeExternal, // User callback - receives full column ID
521
+ },
522
+ },
523
+ });
524
+
525
+ const industryHeader = await screen.findByRole('columnheader', { name: 'Industry' });
526
+ expect(industryHeader).toBeInTheDocument();
527
+
528
+ const firstRow = await screen.findByRole('row', { name: /Child 2/i });
529
+ expect(firstRow).toBeInTheDocument();
530
+
531
+ await userEvent.click(industryHeader);
532
+
533
+ // Internal callback gets trimmed version for API calls
534
+ expect(mockOnSortChangeInternal).toHaveBeenCalledWith('industry', 'ASC');
535
+
536
+ // User callback gets full column ID with direction
537
+ expect(mockOnSortChangeExternal).toHaveBeenCalledWith({
538
+ column: 'metadata.enterprise_0.templateName.industry',
539
+ direction: 'ascending',
540
+ });
541
+ });
542
+
509
543
  test('should call onClick when bulk item action is clicked', async () => {
510
544
  let mockOnClickArg;
511
545
  const mockOnClick = jest.fn(arg => {
@@ -8,7 +8,7 @@ import {
8
8
  JSON_PATCH_OP_REPLACE,
9
9
  JSON_PATCH_OP_TEST,
10
10
  } from '../../../common/constants';
11
- import { FIELD_METADATA, FIELD_NAME, FIELD_EXTENSION } from '../../../constants';
11
+ import { FIELD_METADATA, FIELD_NAME, FIELD_EXTENSION, FIELD_PERMISSIONS } from '../../../constants';
12
12
 
13
13
  describe('features/metadata-based-view/MetadataQueryAPIHelper', () => {
14
14
  let metadataQueryAPIHelper;
@@ -426,27 +426,30 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => {
426
426
  expect(isArray(updatedMetadataQuery.fields)).toBe(true);
427
427
  expect(includes(updatedMetadataQuery.fields, FIELD_NAME)).toBe(true);
428
428
  expect(includes(updatedMetadataQuery.fields, FIELD_EXTENSION)).toBe(true);
429
+ expect(includes(updatedMetadataQuery.fields, FIELD_PERMISSIONS)).toBe(true);
429
430
 
430
431
  if (index === 2) {
431
- // Verify "name" and "extension" are added to pre-existing fields
432
+ // Verify "name", "extension" and "permission" are added to pre-existing fields
432
433
  expect(updatedMetadataQuery.fields).toEqual([
433
434
  ...mdQueryWithoutNameField.fields,
434
435
  FIELD_NAME,
435
436
  FIELD_EXTENSION,
437
+ FIELD_PERMISSIONS,
436
438
  ]);
437
439
  }
438
440
 
439
441
  if (index === 4) {
440
- // Verify "extension" is added when "name" exists but "extension" doesn't
442
+ // Verify "extension" and "permission" are added when "name" exists but "extension" and "permission" don't
441
443
  expect(updatedMetadataQuery.fields).toEqual([
442
444
  ...mdQueryWithoutExtensionField.fields,
443
445
  FIELD_EXTENSION,
446
+ FIELD_PERMISSIONS,
444
447
  ]);
445
448
  }
446
449
 
447
450
  if (index === 5) {
448
- // No change, original query has all necessary fields
449
- expect(updatedMetadataQuery.fields).toEqual(mdQueryWithBothFields.fields);
451
+ // Verify "permission" is added
452
+ expect(updatedMetadataQuery.fields).toEqual([...mdQueryWithBothFields.fields, FIELD_PERMISSIONS]);
450
453
  }
451
454
  },
452
455
  );
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { render, screen } from '../../../test-utils/testing-library';
3
+ import { Notification } from '@box/blueprint-web';
4
+ import { render, screen, waitFor } from '../../../test-utils/testing-library';
4
5
  import MetadataSidePanel, { type MetadataSidePanelProps } from '../MetadataSidePanel';
5
6
 
6
7
  // Mock scrollTo method
@@ -65,13 +66,20 @@ const mockOnClose = jest.fn();
65
66
  describe('elements/content-explorer/MetadataSidePanel', () => {
66
67
  const defaultProps: MetadataSidePanelProps = {
67
68
  currentCollection: mockCollection,
68
- onClose: mockOnClose,
69
69
  metadataTemplate: mockMetadataTemplate,
70
+ onClose: mockOnClose,
71
+ onUpdate: jest.fn(),
72
+ refreshCollection: jest.fn(),
70
73
  selectedItemIds: new Set(['1']),
71
74
  };
72
75
 
73
76
  const renderComponent = (props: Partial<MetadataSidePanelProps> = {}) =>
74
- render(<MetadataSidePanel {...defaultProps} {...props} />);
77
+ render(
78
+ <Notification.Provider>
79
+ <Notification.Viewport />
80
+ <MetadataSidePanel {...defaultProps} {...props} />
81
+ </Notification.Provider>,
82
+ );
75
83
 
76
84
  test('renders the metadata title', () => {
77
85
  renderComponent();
@@ -124,4 +132,138 @@ describe('elements/content-explorer/MetadataSidePanel', () => {
124
132
  const submitButton = screen.getByRole('button', { name: 'Save' });
125
133
  expect(submitButton).toBeInTheDocument();
126
134
  });
135
+
136
+ test('switches back to view mode when cancel button is clicked', async () => {
137
+ renderComponent();
138
+ const editTemplateButton = screen.getByLabelText('Edit Mock Template');
139
+ await userEvent.click(editTemplateButton);
140
+
141
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
142
+ await userEvent.click(cancelButton);
143
+
144
+ // Should be back in view mode
145
+ expect(screen.getByLabelText('Edit Mock Template')).toBeInTheDocument();
146
+ });
147
+
148
+ test('calls onUpdate when form is submitted for single item', async () => {
149
+ const mockUpdateMetadata = jest.fn().mockResolvedValue(undefined);
150
+ renderComponent({ onUpdate: mockUpdateMetadata });
151
+
152
+ const editTemplateButton = screen.getByLabelText('Edit Mock Template');
153
+ await userEvent.click(editTemplateButton);
154
+
155
+ const submitButton = screen.getByRole('button', { name: 'Save' });
156
+ await userEvent.click(submitButton);
157
+
158
+ expect(mockUpdateMetadata).toHaveBeenCalledWith(
159
+ [mockCollection.items[0]],
160
+ expect.any(Array),
161
+ expect.any(Array),
162
+ expect.any(Array),
163
+ expect.any(Function),
164
+ expect.any(Function),
165
+ );
166
+ });
167
+
168
+ test('calls onUpdate when multiple items are selected', async () => {
169
+ const mockUpdateMetadata = jest.fn().mockResolvedValue(undefined);
170
+
171
+ renderComponent({
172
+ selectedItemIds: new Set(['1', '2']),
173
+ onUpdate: mockUpdateMetadata,
174
+ });
175
+
176
+ const editTemplateButton = screen.getByLabelText('Edit Mock Template');
177
+ await userEvent.click(editTemplateButton);
178
+
179
+ const submitButton = screen.getByRole('button', { name: 'Save' });
180
+ await userEvent.click(submitButton);
181
+
182
+ await waitFor(() => {
183
+ expect(mockUpdateMetadata).toHaveBeenCalledTimes(1);
184
+ });
185
+ });
186
+
187
+ test('displays success notification when metadata update succeeds', async () => {
188
+ const mockUpdateMetadata = jest.fn().mockImplementation((_, __, ___, ____, successCallback) => {
189
+ successCallback();
190
+ return Promise.resolve();
191
+ });
192
+ const mockRefreshCollection = jest.fn();
193
+
194
+ renderComponent({
195
+ onUpdate: mockUpdateMetadata,
196
+ refreshCollection: mockRefreshCollection,
197
+ });
198
+
199
+ const editTemplateButton = screen.getByLabelText('Edit Mock Template');
200
+ await userEvent.click(editTemplateButton);
201
+
202
+ const submitButton = screen.getByRole('button', { name: 'Save' });
203
+ await userEvent.click(submitButton);
204
+
205
+ expect(screen.getByText('1 document updated')).toBeInTheDocument();
206
+ expect(mockRefreshCollection).toHaveBeenCalledTimes(1);
207
+ expect(screen.getByLabelText('Edit Mock Template')).toBeInTheDocument(); // Back to view mode
208
+ });
209
+
210
+ test('displays error notification when metadata update fails', async () => {
211
+ const mockUpdateMetadata = jest.fn().mockImplementation((_, __, ___, ____, _____, errorCallback) => {
212
+ errorCallback();
213
+ return Promise.resolve();
214
+ });
215
+
216
+ renderComponent({ onUpdate: mockUpdateMetadata });
217
+
218
+ const editTemplateButton = screen.getByLabelText('Edit Mock Template');
219
+ await userEvent.click(editTemplateButton);
220
+
221
+ const submitButton = screen.getByRole('button', { name: 'Save' });
222
+ await userEvent.click(submitButton);
223
+
224
+ await waitFor(() => {
225
+ expect(screen.getByText('Unable to save changes. Please try again.')).toBeInTheDocument();
226
+ });
227
+ });
228
+
229
+ test('handles "all" selection correctly', () => {
230
+ renderComponent({ selectedItemIds: 'all' });
231
+ const subtitle = screen.getByText('2 files selected');
232
+ expect(subtitle).toBeInTheDocument();
233
+ });
234
+
235
+ test('displays "Multiple Values" for items with different field values', () => {
236
+ const collectionWithDifferentValues = {
237
+ ...mockCollection,
238
+ items: [
239
+ {
240
+ ...mockCollection.items[0],
241
+ metadata: {
242
+ enterprise_123: {
243
+ mockTemplate: {
244
+ alias: 'value-1',
245
+ },
246
+ },
247
+ },
248
+ },
249
+ {
250
+ ...mockCollection.items[1],
251
+ metadata: {
252
+ enterprise_123: {
253
+ mockTemplate: {
254
+ alias: 'value-2',
255
+ },
256
+ },
257
+ },
258
+ },
259
+ ],
260
+ };
261
+
262
+ renderComponent({
263
+ currentCollection: collectionWithDifferentValues,
264
+ selectedItemIds: new Set(['1', '2']),
265
+ });
266
+
267
+ expect(screen.getByText('Multiple Values')).toBeInTheDocument();
268
+ });
127
269
  });
@@ -28,6 +28,12 @@ export const basic = {
28
28
  },
29
29
  };
30
30
 
31
+ export const Modernization = {
32
+ args: {
33
+ enableModernizedComponents: true,
34
+ },
35
+ };
36
+
31
37
  export const openExistingFolder = {
32
38
  play: async ({ canvasElement }) => {
33
39
  const canvas = within(canvasElement);