box-ui-elements 24.0.0-beta.4 → 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 (98) 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/content-explorer/Content.js +2 -1
  15. package/es/elements/content-explorer/Content.js.map +1 -1
  16. package/es/elements/content-explorer/ContentExplorer.js +19 -5
  17. package/es/elements/content-explorer/ContentExplorer.js.map +1 -1
  18. package/es/elements/content-explorer/MetadataQueryAPIHelper.js +61 -4
  19. package/es/elements/content-explorer/MetadataQueryAPIHelper.js.map +1 -1
  20. package/es/elements/content-explorer/MetadataSidePanel.js +40 -14
  21. package/es/elements/content-explorer/MetadataSidePanel.js.map +1 -1
  22. package/es/elements/content-explorer/MetadataViewContainer.js +55 -4
  23. package/es/elements/content-explorer/MetadataViewContainer.js.map +1 -1
  24. package/es/elements/content-explorer/stories/tests/MetadataView-visual.stories.js +61 -13
  25. package/es/elements/content-explorer/stories/tests/MetadataView-visual.stories.js.map +1 -1
  26. package/es/elements/content-explorer/utils.js +140 -12
  27. package/es/elements/content-explorer/utils.js.map +1 -1
  28. package/es/src/elements/content-explorer/ContentExplorer.d.ts +11 -3
  29. package/es/src/elements/content-explorer/MetadataQueryAPIHelper.d.ts +11 -1
  30. package/es/src/elements/content-explorer/MetadataSidePanel.d.ts +6 -3
  31. package/es/src/elements/content-explorer/MetadataViewContainer.d.ts +3 -1
  32. package/es/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.d.ts +1 -0
  33. package/es/src/elements/content-explorer/utils.d.ts +9 -3
  34. package/i18n/bn-IN.js +4 -0
  35. package/i18n/bn-IN.properties +4 -0
  36. package/i18n/da-DK.js +4 -0
  37. package/i18n/da-DK.properties +4 -0
  38. package/i18n/de-DE.js +5 -1
  39. package/i18n/de-DE.properties +4 -0
  40. package/i18n/en-AU.js +4 -0
  41. package/i18n/en-AU.properties +4 -0
  42. package/i18n/en-CA.js +4 -0
  43. package/i18n/en-CA.properties +4 -0
  44. package/i18n/en-GB.js +4 -0
  45. package/i18n/en-GB.properties +4 -0
  46. package/i18n/en-US.js +4 -0
  47. package/i18n/en-US.properties +8 -0
  48. package/i18n/en-x-pseudo.js +4 -0
  49. package/i18n/es-419.js +5 -1
  50. package/i18n/es-419.properties +4 -0
  51. package/i18n/es-ES.js +5 -1
  52. package/i18n/es-ES.properties +4 -0
  53. package/i18n/fi-FI.js +4 -0
  54. package/i18n/fi-FI.properties +4 -0
  55. package/i18n/fr-CA.js +4 -0
  56. package/i18n/fr-CA.properties +4 -0
  57. package/i18n/fr-FR.js +4 -0
  58. package/i18n/fr-FR.properties +4 -0
  59. package/i18n/hi-IN.js +4 -0
  60. package/i18n/hi-IN.properties +4 -0
  61. package/i18n/it-IT.js +4 -0
  62. package/i18n/it-IT.properties +4 -0
  63. package/i18n/ja-JP.js +6 -2
  64. package/i18n/ja-JP.properties +6 -2
  65. package/i18n/ko-KR.js +4 -0
  66. package/i18n/ko-KR.properties +4 -0
  67. package/i18n/nb-NO.js +4 -0
  68. package/i18n/nb-NO.properties +4 -0
  69. package/i18n/nl-NL.js +4 -0
  70. package/i18n/nl-NL.properties +4 -0
  71. package/i18n/pl-PL.js +4 -0
  72. package/i18n/pl-PL.properties +4 -0
  73. package/i18n/pt-BR.js +4 -0
  74. package/i18n/pt-BR.properties +4 -0
  75. package/i18n/ru-RU.js +5 -1
  76. package/i18n/ru-RU.properties +4 -0
  77. package/i18n/sv-SE.js +4 -0
  78. package/i18n/sv-SE.properties +4 -0
  79. package/i18n/tr-TR.js +5 -1
  80. package/i18n/tr-TR.properties +4 -0
  81. package/i18n/zh-CN.js +4 -0
  82. package/i18n/zh-CN.properties +4 -0
  83. package/i18n/zh-TW.js +4 -0
  84. package/i18n/zh-TW.properties +4 -0
  85. package/package.json +1 -1
  86. package/src/api/Metadata.js +110 -12
  87. package/src/api/__tests__/Metadata.test.js +120 -0
  88. package/src/elements/common/messages.js +25 -0
  89. package/src/elements/content-explorer/Content.tsx +1 -0
  90. package/src/elements/content-explorer/ContentExplorer.tsx +220 -181
  91. package/src/elements/content-explorer/MetadataQueryAPIHelper.ts +89 -4
  92. package/src/elements/content-explorer/MetadataSidePanel.tsx +55 -14
  93. package/src/elements/content-explorer/MetadataViewContainer.tsx +61 -1
  94. package/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +36 -2
  95. package/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts +8 -5
  96. package/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx +145 -3
  97. package/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +54 -8
  98. package/src/elements/content-explorer/utils.ts +150 -13
@@ -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
  });
@@ -3,7 +3,9 @@ import { http, HttpResponse } from 'msw';
3
3
  import { Download, SignMeOthers } from '@box/blueprint-web-assets/icons/Fill/index';
4
4
  import { Sign } from '@box/blueprint-web-assets/icons/Line';
5
5
  import { expect, fn, userEvent, waitFor, within, screen } from 'storybook/test';
6
+
6
7
  import noop from 'lodash/noop';
8
+ import orderBy from 'lodash/orderBy';
7
9
 
8
10
  import ContentExplorer from '../../ContentExplorer';
9
11
  import { DEFAULT_HOSTNAME_API } from '../../../../constants';
@@ -138,17 +140,16 @@ export const metadataViewV2: Story = {
138
140
  args: metadataViewV2ElementProps,
139
141
  };
140
142
 
141
- // @TODO Assert that rows are actually sorted in a different order, once handleSortChange is implemented
142
143
  export const metadataViewV2SortsFromHeader: Story = {
143
144
  args: metadataViewV2ElementProps,
144
145
  play: async ({ canvas }) => {
145
- await waitFor(() => {
146
- expect(canvas.getByRole('row', { name: /Industry/i })).toBeInTheDocument();
147
- });
146
+ const industryHeader = await canvas.findByRole('columnheader', { name: 'Industry' });
147
+ expect(industryHeader).toBeInTheDocument();
148
+
149
+ const firstRow = await canvas.findByRole('row', { name: /Child 2/i });
150
+ expect(firstRow).toBeInTheDocument();
148
151
 
149
- const firstRow = canvas.getByRole('row', { name: /Industry/i });
150
- const industryHeader = within(firstRow).getByRole('columnheader', { name: 'Industry' });
151
- userEvent.click(industryHeader);
152
+ await userEvent.click(industryHeader);
152
153
  },
153
154
  };
154
155
 
@@ -237,6 +238,37 @@ export const metadataViewV2WithBulkItemActionMenuShowsItemActionMenu: Story = {
237
238
  },
238
239
  };
239
240
 
241
+ export const sidePanelOpenWithMultipleItemsSelected: Story = {
242
+ args: {
243
+ ...metadataViewV2ElementProps,
244
+ metadataViewProps: {
245
+ columns,
246
+ tableProps: {
247
+ isSelectAllEnabled: true,
248
+ },
249
+ },
250
+ },
251
+
252
+ play: async ({ canvas }) => {
253
+ await waitFor(() => {
254
+ expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument();
255
+ });
256
+
257
+ // Select the first row by clicking its checkbox
258
+ const firstItem = canvas.getByRole('row', { name: /Child 2/i });
259
+ const checkbox = within(firstItem).getByRole('checkbox');
260
+ await userEvent.click(checkbox);
261
+
262
+ // Select the second row by clicking its checkbox
263
+ const secondItem = canvas.getAllByRole('row', { name: /Child 1/i })[0];
264
+ const secondCheckbox = within(secondItem).getByRole('checkbox');
265
+ await userEvent.click(secondCheckbox);
266
+
267
+ const metadataButton = canvas.getByRole('button', { name: 'Metadata' });
268
+ await userEvent.click(metadataButton);
269
+ },
270
+ };
271
+
240
272
  const meta: Meta<typeof ContentExplorer> = {
241
273
  title: 'Elements/ContentExplorer/tests/MetadataView/visual',
242
274
  component: ContentExplorer,
@@ -248,7 +280,21 @@ const meta: Meta<typeof ContentExplorer> = {
248
280
  parameters: {
249
281
  msw: {
250
282
  handlers: [
251
- http.post(`${DEFAULT_HOSTNAME_API}/2.0/metadata_queries/execute_read`, () => {
283
+ // Note that the Metadata API backend normally handles the sorting. The mocks below simulate the sorting for specific cases, but may not 100% accurately reflect the backend behavior.
284
+ http.post(`${DEFAULT_HOSTNAME_API}/2.0/metadata_queries/execute_read`, async ({ request }) => {
285
+ const body = await request.clone().json();
286
+ const orderByDirection = body.order_by[0].direction;
287
+ const orderByFieldKey = body.order_by[0].field_key;
288
+
289
+ // Hardcoded case for sorting by industry
290
+ if (orderByFieldKey === `industry` && orderByDirection === 'ASC') {
291
+ const sortedMetadata = orderBy(
292
+ mockMetadata.entries,
293
+ 'metadata.enterprise_0.templateName.industry',
294
+ 'asc',
295
+ );
296
+ return HttpResponse.json({ ...mockMetadata, entries: sortedMetadata });
297
+ }
252
298
  return HttpResponse.json(mockMetadata);
253
299
  }),
254
300
  http.get(`${DEFAULT_HOSTNAME_API}/2.0/metadata_templates/enterprise/templateName/schema`, () => {