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.
- package/dist/explorer.css +1 -1
- package/dist/explorer.js +1 -1
- package/dist/openwith.js +1 -1
- package/dist/picker.js +1 -1
- package/dist/preview.js +1 -1
- package/dist/sharing.js +1 -1
- package/dist/sidebar.js +1 -1
- package/dist/uploader.js +1 -1
- package/es/api/Metadata.js +98 -13
- package/es/api/Metadata.js.flow +110 -12
- package/es/api/Metadata.js.map +1 -1
- package/es/elements/common/messages.js +16 -0
- package/es/elements/common/messages.js.flow +25 -0
- package/es/elements/common/messages.js.map +1 -1
- package/es/elements/content-explorer/Content.js +5 -2
- package/es/elements/content-explorer/Content.js.map +1 -1
- package/es/elements/content-explorer/ContentExplorer.js +31 -6
- package/es/elements/content-explorer/ContentExplorer.js.map +1 -1
- package/es/elements/content-explorer/MetadataQueryAPIHelper.js +164 -10
- package/es/elements/content-explorer/MetadataQueryAPIHelper.js.map +1 -1
- package/es/elements/content-explorer/MetadataQueryBuilder.js +115 -0
- package/es/elements/content-explorer/MetadataQueryBuilder.js.map +1 -0
- package/es/elements/content-explorer/MetadataSidePanel.js +40 -14
- package/es/elements/content-explorer/MetadataSidePanel.js.map +1 -1
- package/es/elements/content-explorer/MetadataViewContainer.js +133 -36
- package/es/elements/content-explorer/MetadataViewContainer.js.map +1 -1
- package/es/elements/content-explorer/stories/MetadataView.stories.js +3 -25
- package/es/elements/content-explorer/stories/MetadataView.stories.js.map +1 -1
- package/es/elements/content-explorer/stories/tests/MetadataView-visual.stories.js +65 -29
- package/es/elements/content-explorer/stories/tests/MetadataView-visual.stories.js.map +1 -1
- package/es/elements/content-explorer/utils.js +140 -12
- package/es/elements/content-explorer/utils.js.map +1 -1
- package/es/src/elements/common/__mocks__/mockMetadata.d.ts +8 -24
- package/es/src/elements/content-explorer/Content.d.ts +4 -3
- package/es/src/elements/content-explorer/ContentExplorer.d.ts +19 -6
- package/es/src/elements/content-explorer/MetadataQueryAPIHelper.d.ts +22 -3
- package/es/src/elements/content-explorer/MetadataQueryBuilder.d.ts +27 -0
- package/es/src/elements/content-explorer/MetadataSidePanel.d.ts +6 -3
- package/es/src/elements/content-explorer/MetadataViewContainer.d.ts +10 -4
- package/es/src/elements/content-explorer/__tests__/MetadataQueryBuilder.test.d.ts +1 -0
- package/es/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.d.ts +1 -0
- package/es/src/elements/content-explorer/utils.d.ts +9 -3
- package/i18n/bn-IN.js +4 -0
- package/i18n/bn-IN.properties +12 -0
- package/i18n/da-DK.js +4 -0
- package/i18n/da-DK.properties +12 -0
- package/i18n/de-DE.js +5 -1
- package/i18n/de-DE.properties +12 -0
- package/i18n/en-AU.js +4 -0
- package/i18n/en-AU.properties +12 -0
- package/i18n/en-CA.js +4 -0
- package/i18n/en-CA.properties +12 -0
- package/i18n/en-GB.js +4 -0
- package/i18n/en-GB.properties +12 -0
- package/i18n/en-US.js +4 -0
- package/i18n/en-US.properties +8 -0
- package/i18n/en-x-pseudo.js +4 -0
- package/i18n/es-419.js +5 -1
- package/i18n/es-419.properties +12 -0
- package/i18n/es-ES.js +5 -1
- package/i18n/es-ES.properties +12 -0
- package/i18n/fi-FI.js +4 -0
- package/i18n/fi-FI.properties +12 -0
- package/i18n/fr-CA.js +4 -0
- package/i18n/fr-CA.properties +12 -0
- package/i18n/fr-FR.js +4 -0
- package/i18n/fr-FR.properties +12 -0
- package/i18n/hi-IN.js +4 -0
- package/i18n/hi-IN.properties +12 -0
- package/i18n/it-IT.js +4 -0
- package/i18n/it-IT.properties +12 -0
- package/i18n/ja-JP.js +6 -2
- package/i18n/ja-JP.properties +14 -2
- package/i18n/ko-KR.js +4 -0
- package/i18n/ko-KR.properties +12 -0
- package/i18n/nb-NO.js +4 -0
- package/i18n/nb-NO.properties +12 -0
- package/i18n/nl-NL.js +4 -0
- package/i18n/nl-NL.properties +12 -0
- package/i18n/pl-PL.js +4 -0
- package/i18n/pl-PL.properties +12 -0
- package/i18n/pt-BR.js +4 -0
- package/i18n/pt-BR.properties +12 -0
- package/i18n/ru-RU.js +5 -1
- package/i18n/ru-RU.properties +12 -0
- package/i18n/sv-SE.js +4 -0
- package/i18n/sv-SE.properties +12 -0
- package/i18n/tr-TR.js +5 -1
- package/i18n/tr-TR.properties +12 -0
- package/i18n/zh-CN.js +4 -0
- package/i18n/zh-CN.properties +12 -0
- package/i18n/zh-TW.js +4 -0
- package/i18n/zh-TW.properties +12 -0
- package/package.json +3 -3
- package/src/api/Metadata.js +110 -12
- package/src/api/__tests__/Metadata.test.js +120 -0
- package/src/elements/common/__mocks__/mockMetadata.ts +7 -11
- package/src/elements/common/messages.js +25 -0
- package/src/elements/content-explorer/Content.tsx +9 -2
- package/src/elements/content-explorer/ContentExplorer.tsx +71 -17
- package/src/elements/content-explorer/MetadataQueryAPIHelper.ts +199 -8
- package/src/elements/content-explorer/MetadataQueryBuilder.ts +159 -0
- package/src/elements/content-explorer/MetadataSidePanel.tsx +55 -14
- package/src/elements/content-explorer/MetadataViewContainer.tsx +164 -29
- package/src/elements/content-explorer/__tests__/Content.test.tsx +1 -0
- package/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +38 -7
- package/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts +428 -12
- package/src/elements/content-explorer/__tests__/MetadataQueryBuilder.test.ts +419 -0
- package/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx +145 -3
- package/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx +413 -9
- package/src/elements/content-explorer/stories/MetadataView.stories.tsx +3 -21
- package/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +56 -21
- 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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
|
3
|
-
import {
|
|
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
|
|
43
|
-
fields
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
?
|
|
49
|
-
:
|
|
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
|
|
126
|
+
const { initialFilterValues: initialFilterValuesProp, onFilterSubmit } = actionBarProps ?? {};
|
|
127
|
+
|
|
128
|
+
const newColumns = React.useMemo(() => {
|
|
129
|
+
let clonedColumns = cloneDeep(columns);
|
|
69
130
|
|
|
70
|
-
|
|
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
|
-
|
|
175
|
+
fields?.map(field => {
|
|
76
176
|
return {
|
|
77
|
-
id:
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
196
|
+
onMetadataFilter(transformed);
|
|
197
|
+
if (onFilterSubmit) {
|
|
198
|
+
onFilterSubmit(transformed);
|
|
199
|
+
}
|
|
101
200
|
},
|
|
102
|
-
[
|
|
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,
|
|
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
|
|
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;
|
|
@@ -454,7 +454,7 @@ describe('elements/content-explorer/ContentExplorer', () => {
|
|
|
454
454
|
textValue: 'Name',
|
|
455
455
|
id: 'name',
|
|
456
456
|
type: 'string' as const,
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 => {
|