Skip to content

Commit e3793ae

Browse files
GUI Issue-#3737: faceted search export templates control (#3742)
* GUI Issue-#3737: faceted search export templates control * GUI Issue-#3737: export template modal text adjustments * GUI Issue-#3737: export template modal text adjustments * GUI Issue-#3737: export template modal small fix
1 parent d24bb23 commit e3793ae

File tree

4 files changed

+286
-24
lines changed

4 files changed

+286
-24
lines changed

client/src/components/search/faceted-search/controls/export-button/index.js

Lines changed: 218 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@
1616

1717
import React from 'react';
1818
import PropTypes from 'prop-types';
19+
import {computed} from 'mobx';
20+
import {inject, observer} from 'mobx-react';
21+
import {Link} from 'react-router';
1922
import {
2023
Dropdown,
2124
Icon,
22-
message
25+
message,
26+
Button,
27+
Modal
2328
} from 'antd';
24-
import Menu, {MenuItem} from 'rc-menu';
29+
import Menu, {MenuItem, Divider} from 'rc-menu';
2530
import FileSaver from 'file-saver';
2631
import ExportConfigurationModal from './configuration-modal';
2732
import {getSortingPayload} from '../../utilities';
@@ -34,8 +39,14 @@ import {
3439
CloudPath,
3540
MountPath
3641
} from '../../utilities/document-columns';
42+
import roleModel from '../../../../../utils/roleModel';
3743
import FacetedSearchExport from '../../../../../models/search/faceted-search-export';
44+
// eslint-disable-next-line max-len
45+
import FacetedSearchExportTemplatesSave from '../../../../../models/search/faceted-search-export-templates-save';
46+
// eslint-disable-next-line max-len
47+
import FacetedSearchExportTemplates from '../../../../../models/search/faceted-search-export-templates';
3848
import checkBlob from '../../../../../utils/check-blob';
49+
import displayDate from '../../../../../utils/displayDate';
3950

4051
const exportVOColumns = {
4152
[Name.key]: 'includeName',
@@ -47,9 +58,33 @@ const exportVOColumns = {
4758
[MountPath.key]: 'includeMountPath'
4859
};
4960

50-
function ExportMenu ({onExport, onConfigure}) {
61+
function parseExportTemplates (rawTemplates = {}) {
62+
return Object.entries(rawTemplates).map(([key, template]) => ({
63+
...template,
64+
key
65+
}));
66+
}
67+
68+
function checkTemplatePermissions (userInfo, template) {
69+
if (!userInfo) {
70+
return false;
71+
}
72+
const {userName, groups = []} = userInfo;
73+
return template.permissions.some(permission => {
74+
if (permission.principal) {
75+
return permission.name === userName;
76+
}
77+
return roleModel.userHasRole(
78+
userInfo,
79+
(permission.name || '').toUpperCase()
80+
) || groups.includes(permission.nzme);
81+
});
82+
}
83+
84+
function ExportMenu ({onExport, onExportTemplate, onConfigure, templates}) {
5185
const handle = ({key}) => {
52-
switch (key) {
86+
const [exportType, exportKey] = key.split('|');
87+
switch (exportType) {
5388
case 'export':
5489
if (typeof onExport === 'function') {
5590
onExport();
@@ -60,10 +95,24 @@ function ExportMenu ({onExport, onConfigure}) {
6095
onConfigure();
6196
}
6297
break;
98+
case 'template':
99+
if (typeof onExportTemplate === 'function') {
100+
const currentTemplate = templates.find(({key}) => key === exportKey);
101+
onExportTemplate(currentTemplate);
102+
}
103+
break;
63104
default:
64105
break;
65106
}
66107
};
108+
const templatesSection = templates.length ? [
109+
<Divider key="divider" />,
110+
...templates.map(template => (
111+
<MenuItem key={`template|${template.key}`}>
112+
{template['friendly_name'] || template.key}
113+
</MenuItem>
114+
))
115+
] : [];
67116
return (
68117
<Menu
69118
onClick={handle}
@@ -78,17 +127,64 @@ function ExportMenu ({onExport, onConfigure}) {
78127
<Icon type="bars" style={{marginRight: 10}} />
79128
Custom configuration
80129
</MenuItem>
130+
{templatesSection}
81131
</Menu>
82132
);
83133
}
84134

135+
@inject('preferences', 'authenticatedUserInfo', 'dataStorages')
136+
@observer
85137
class ExportButton extends React.Component {
86138
state = {
87139
pending: false,
88140
modalVisible: false,
89141
dropdownVisible: false
90142
};
91143

144+
@computed
145+
get exportTemplates () {
146+
const {preferences} = this.props;
147+
if (preferences?.loaded) {
148+
return preferences.searchExportTemplates;
149+
}
150+
return undefined;
151+
}
152+
153+
@computed
154+
get isAdmin () {
155+
const {authenticatedUserInfo} = this.props;
156+
if (authenticatedUserInfo.loaded) {
157+
return authenticatedUserInfo.value.admin;
158+
}
159+
return false;
160+
}
161+
162+
getFacetedSearchExportPayload = (configuration) => {
163+
const {
164+
advanced,
165+
columns = [],
166+
query: currentQuery,
167+
filters = {},
168+
sorting = [],
169+
facets = []
170+
} = this.props;
171+
const columnsToExport = configuration && configuration.length
172+
? columns.filter((aColumn) => configuration.includes(aColumn.key))
173+
: columns.slice();
174+
const keys = columnsToExport.map((column) => column.key);
175+
const metadataFields = keys.filter((key) => !exportVOColumns[key]);
176+
return {
177+
query: !advanced && currentQuery
178+
? `*${currentQuery}*`
179+
: (currentQuery || '*'),
180+
filters,
181+
sorts: getSortingPayload(sorting),
182+
metadataFields,
183+
facets: facets.map((facet) => facet.name),
184+
highlight: false
185+
};
186+
};
187+
92188
openModal = () => {
93189
this.setState({
94190
modalVisible: true,
@@ -118,39 +214,22 @@ class ExportButton extends React.Component {
118214
this.setState(state, () => resolve()));
119215
const hide = message.loading('Exporting...', 0);
120216
await setStateAwaited({pending: true});
121-
const {
122-
advanced,
123-
columns = [],
124-
query: currentQuery,
125-
filters = {},
126-
sorting = [],
127-
facets = []
128-
} = this.props;
217+
const {columns = []} = this.props;
129218
const columnsToExport = configuration && configuration.length
130219
? columns.filter((aColumn) => configuration.includes(aColumn.key))
131220
: columns.slice();
132221
try {
133222
const keys = columnsToExport.map((column) => column.key);
134-
const metadataFields = keys.filter((key) => !exportVOColumns[key]);
135223
const facetedSearchExportVO = Object.entries(exportVOColumns)
136224
.reduce((acc, [key, exportVOKey]) => ({
137225
...acc,
138226
[exportVOKey]: !!keys.includes(key)
139227
}), {delimiter: ','});
140228
const csvFileName = 'export.csv';
141229
const payload = {
142-
csvFileName: 'export.csv',
230+
csvFileName,
143231
facetedSearchExportVO,
144-
facetedSearchRequest: {
145-
query: !advanced && currentQuery
146-
? `*${currentQuery}*`
147-
: (currentQuery || '*'),
148-
filters,
149-
sorts: getSortingPayload(sorting),
150-
metadataFields,
151-
facets: facets.map((facet) => facet.name),
152-
highlight: false
153-
}
232+
facetedSearchRequest: this.getFacetedSearchExportPayload(configuration)
154233
};
155234
const request = new FacetedSearchExport();
156235
await request.send(payload);
@@ -175,6 +254,89 @@ class ExportButton extends React.Component {
175254

176255
onDefaultExport = () => this.onExport();
177256

257+
onExportTemplate = async (template, payload) => {
258+
const downloadExport = async (template) => {
259+
const request = new FacetedSearchExportTemplates(template.key);
260+
await request.send(payload);
261+
if (request.error) {
262+
return message.error(request.error, 5);
263+
}
264+
if (request.value instanceof Blob) {
265+
const error = await checkBlob(request.value, 'Error exporting search results');
266+
if (error) {
267+
return message.error(error.message, 5);
268+
}
269+
const fileName = `${template.key}-${displayDate(Date.now(), 'YYYY-MM-DD HH:mm:ss')}.xls`;
270+
FileSaver.saveAs(request.value, fileName);
271+
}
272+
};
273+
const saveExport = async (template, payload) => {
274+
const request = new FacetedSearchExportTemplatesSave(template.key);
275+
await request.send(payload);
276+
if (request.error) {
277+
return message.error(request.error, 5);
278+
}
279+
this.setState({savedExport: request.value});
280+
};
281+
const uploadToBucket = !!template.save_to;
282+
this.closeModal();
283+
const setStateAwaited = (state) =>
284+
new Promise((resolve) =>
285+
this.setState(state, () => resolve()));
286+
const hide = message.loading(uploadToBucket ? 'Exporting...' : 'Downloading...', 0);
287+
await setStateAwaited({pending: true});
288+
try {
289+
const payload = this.getFacetedSearchExportPayload();
290+
if (uploadToBucket) {
291+
await saveExport(template, payload);
292+
} else {
293+
await downloadExport(template, payload);
294+
}
295+
} catch (e) {
296+
message.error(e.message, 5);
297+
} finally {
298+
hide();
299+
this.setState({
300+
pending: false
301+
});
302+
}
303+
};
304+
305+
closeExportResultModal = () => this.setState({savedExport: undefined});
306+
307+
openExportResultModal = template => this.setState({savedExport: template});
308+
309+
renderSavedExportContent = () => {
310+
const {savedExport} = this.state;
311+
const getStorageById = (id) => {
312+
const {
313+
dataStorages
314+
} = this.props;
315+
if (dataStorages.loaded) {
316+
return (dataStorages.value || []).find(d => Number(d.id) === Number(id));
317+
}
318+
return undefined;
319+
};
320+
if (!savedExport) {
321+
return null;
322+
}
323+
const storage = getStorageById(savedExport.storageId);
324+
const path = savedExport.storagePath.split('/');
325+
const file = path.pop();
326+
const folder = path.join('/');
327+
return (
328+
<div style={{paddingRight: 25}}>
329+
Data exported to
330+
<Link
331+
style={{marginLeft: 5}}
332+
to={`storage/${savedExport.storageId}?path=${folder}`}
333+
>
334+
{`${storage?.name || savedExport.storageId}/${folder}/${file}`}
335+
</Link>.
336+
</div>
337+
);
338+
};
339+
178340
render () {
179341
const {
180342
columns,
@@ -187,12 +349,25 @@ class ExportButton extends React.Component {
187349
modalVisible,
188350
dropdownVisible
189351
} = this.state;
352+
const templates = parseExportTemplates(this.exportTemplates)
353+
.filter(template => {
354+
if (!template.permissions || this.isAdmin) {
355+
return true;
356+
}
357+
if (!this.props.authenticatedUserInfo.loaded) {
358+
return false;
359+
}
360+
return checkTemplatePermissions(this.props.authenticatedUserInfo.value, template);
361+
});
190362
return (
191363
<Dropdown.Button
192364
overlay={(
193365
<ExportMenu
194366
onExport={this.onDefaultExport}
367+
onExportTemplate={this.onExportTemplate}
195368
onConfigure={this.onConfigure}
369+
templates={templates}
370+
storages={this.storages}
196371
/>
197372
)}
198373
className={className}
@@ -212,6 +387,25 @@ class ExportButton extends React.Component {
212387
onExport={this.onExport}
213388
columns={columns}
214389
/>
390+
<Modal
391+
title={null}
392+
visible={!!this.state.savedExport}
393+
onCancel={this.closeExportResultModal}
394+
width="fit-content"
395+
style={{
396+
minWidth: '30vw',
397+
maxWidth: '80vw'
398+
}}
399+
footer={
400+
<Button
401+
type="primary"
402+
onClick={this.closeExportResultModal}>
403+
OK
404+
</Button>
405+
}
406+
>
407+
{this.renderSavedExportContent()}
408+
</Modal>
215409
</Dropdown.Button>
216410
);
217411
}

client/src/models/preferences/PreferencesLoad.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,19 @@ class PreferencesLoad extends Remote {
104104
return !!this.getPreferenceValue('search.elastic.host');
105105
}
106106

107+
@computed
108+
get searchExportTemplates () {
109+
const value = this.getPreferenceValue('search.export.template.mapping');
110+
if (value) {
111+
try {
112+
return JSON.parse(value);
113+
} catch (e) {
114+
console.warn('Error parsing "search.export.template.mapping:', e);
115+
}
116+
}
117+
return undefined;
118+
}
119+
107120
@computed
108121
get billingEnabled () {
109122
const value = this.getPreferenceValue('billing.reports.enabled');
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2017-2024 EPAM Systems, Inc. (https://www.epam.com/)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import RemotePost from '../basic/RemotePost';
18+
19+
// Upload template export result to bucket (template.save_to)
20+
class FacetSearchExportTemplatesSave extends RemotePost {
21+
constructor (templateId) {
22+
super();
23+
this.url = `/search/facet/export/templates/save?templateId=${templateId}`;
24+
}
25+
}
26+
27+
export default FacetSearchExportTemplatesSave;

0 commit comments

Comments
 (0)