1) Port the file/storage manager to React. Fixes #7313

2) Allow users to delete files/folders from the storage manager. Fixes #4607
3) Allow users to search within the file/storage manager. Fixes #7389
4) Fixed an issue where new folders cannot be created in the save dialog. Fixes #7524
This commit is contained in:
Aditya Toshniwal
2022-07-19 15:27:47 +05:30
committed by Akshay Joshi
parent 4585597388
commit 4808df5e95
76 changed files with 2907 additions and 3927 deletions

View File

@@ -25,11 +25,11 @@ import _ from 'lodash';
import gettext from 'sources/gettext';
import { SCHEMA_STATE_ACTIONS, StateUtilsContext } from '.';
import FormView, { getFieldMetaData } from './FormView';
import { confirmDeleteRow } from '../helpers/legacyConnector';
import CustomPropTypes from 'sources/custom_prop_types';
import { evalFunc } from 'sources/utils';
import { DepListenerContext } from './DepListener';
import { useIsMounted } from '../custom_hooks';
import Notify from '../helpers/Notifier';
const useStyles = makeStyles((theme)=>({
grid: {
@@ -303,15 +303,21 @@ export default function DataGridView({
return (
<PgIconButton data-test="delete-row" title={gettext('Delete row')} icon={<DeleteRoundedIcon fontSize="small" />}
onClick={()=>{
confirmDeleteRow(()=>{
/* Get the changes on dependent fields as well */
dataDispatch({
type: SCHEMA_STATE_ACTIONS.DELETE_ROW,
path: accessPath,
value: row.index,
});
}, ()=>{/*This is intentional (SonarQube)*/}, props.customDeleteTitle, props.customDeleteMsg);
Notify.confirm(
props.customDeleteTitle || gettext('Delete Row'),
props.customDeleteMsg || gettext('Are you sure you wish to delete this row?'),
function() {
dataDispatch({
type: SCHEMA_STATE_ACTIONS.DELETE_ROW,
path: accessPath,
value: row.index,
});
return true;
},
function() {
return true;
}
);
}} className={classes.gridRowButton} disabled={!canDeleteRow} />
);
}

View File

@@ -125,6 +125,9 @@ basicSettings = createMuiTheme(basicSettings, {
},
adornedEnd: {
paddingRight: basicSettings.spacing(0.75),
},
marginDense: {
height: '28px',
}
},
MuiAccordion: {

View File

@@ -2835,106 +2835,6 @@ define([
].join('\n')),
});
/*
* Input File Control: This control is used with Storage Manager Dialog,
* It allows user to perform following operations:
* - Select File
* - Select Folder
* - Create File
* - Opening Storage Manager Dialog itself.
*/
Backform.FileControl = Backform.InputControl.extend({
defaults: {
type: 'text',
label: '',
min: undefined,
max: undefined,
maxlength: 255,
extraClasses: [],
dialog_title: '',
btn_primary: '',
helpMessage: null,
dialog_type: 'select_file',
},
initialize: function() {
Backform.InputControl.prototype.initialize.apply(this, arguments);
},
template: _.template([
'<label class="<%=Backform.controlLabelClassName%>" for="<%=cId%>"><%=label%></label>',
'<div class="<%=Backform.controlsClassName%>">',
'<div class="input-group">',
'<input type="<%=type%>" id="<%=cId%>" class="form-control <%=extraClasses.join(\' \')%>" name="<%=name%>" min="<%=min%>" max="<%=max%>"maxlength="<%=maxlength%>" value="<%-value%>" placeholder="<%-placeholder%>" <%=disabled ? "disabled" : ""%> <%=readonly ? "readonly aria-readonly=true" : ""%> <%=required ? "required" : ""%> />',
'<div class="input-group-append">',
'<button class="btn btn-primary-icon fa fa-ellipsis-h select_item" <%=disabled ? "disabled" : ""%> <%=readonly ? "disabled" : ""%> aria-hidden="true" aria-label="' + gettext('Select file') + '" title="' + gettext('Select file') + '"></button>',
'</div>',
'</div>',
'<% if (helpMessage && helpMessage.length) { %>',
'<span class="<%=Backform.helpMessageClassName%>"><%=helpMessage%></span>',
'<% } %>',
'</div>',
].join('\n')),
events: function() {
// Inherit all default events of InputControl
return _.extend({}, Backform.InputControl.prototype.events, {
'click .select_item': 'onSelect',
});
},
onSelect: function() {
var dialog_type = this.field.get('dialog_type'),
supp_types = this.field.get('supp_types'),
btn_primary = this.field.get('btn_primary'),
dialog_title = this.field.get('dialog_title'),
params = {
supported_types: supp_types,
dialog_type: dialog_type,
dialog_title: dialog_title,
btn_primary: btn_primary,
};
pgAdmin.FileManager.init();
pgAdmin.FileManager.show_dialog(params);
// Listen click events of Storage Manager dialog buttons
this.listen_file_dlg_events();
},
storage_dlg_hander: function(value) {
var attrArr = this.field.get('name').split('.'),
name = attrArr.shift();
this.remove_file_dlg_event_listeners();
// Set selected value into the model
this.model.set(name, decodeURI(value));
this.$el.find('input[type=text]').focus();
},
storage_close_dlg_hander: function() {
this.remove_file_dlg_event_listeners();
},
listen_file_dlg_events: function() {
pgAdmin.Browser.Events.on('pgadmin-storage:finish_btn:' + this.field.get('dialog_type'), this.storage_dlg_hander, this);
pgAdmin.Browser.Events.on('pgadmin-storage:cancel_btn:' + this.field.get('dialog_type'), this.storage_close_dlg_hander, this);
},
remove_file_dlg_event_listeners: function() {
pgAdmin.Browser.Events.off('pgadmin-storage:finish_btn:' + this.field.get('dialog_type'), this.storage_dlg_hander, this);
pgAdmin.Browser.Events.off('pgadmin-storage:cancel_btn:' + this.field.get('dialog_type'), this.storage_close_dlg_hander, this);
},
clearInvalid: function() {
Backform.InputControl.prototype.clearInvalid.apply(this, arguments);
this.$el.removeClass('pgadmin-file-has-error');
return this;
},
updateInvalid: function() {
Backform.InputControl.prototype.updateInvalid.apply(this, arguments);
// Introduce a new class to fix the error icon placement on the control
this.$el.addClass('pgadmin-file-has-error');
},
disable_button: function() {
this.$el.find('button.select_item').attr('disabled', 'disabled');
},
enable_button: function() {
this.$el.find('button.select_item').removeAttr('disabled');
},
});
Backform.DatetimepickerControl =
Backform.InputControl.extend({
defaults: {

View File

@@ -12,10 +12,10 @@ import Notify from '../../static/js/helpers/Notifier';
define([
'sources/gettext', 'underscore', 'jquery', 'backbone', 'backform', 'backgrid', 'alertify',
'moment', 'bignumber', 'codemirror', 'sources/utils', 'sources/keyboard_shortcuts', 'sources/select2/configure_show_on_scroll',
'sources/window', 'sources/url_for', 'bootstrap.datetimepicker', 'backgrid.filter', 'bootstrap.toggle',
'sources/window', 'bootstrap.datetimepicker', 'backgrid.filter', 'bootstrap.toggle',
], function(
gettext, _, $, Backbone, Backform, Backgrid, Alertify, moment, BigNumber, CodeMirror,
commonUtils, keyboardShortcuts, configure_show_on_scroll, pgWindow, url_for
commonUtils, keyboardShortcuts, configure_show_on_scroll, pgWindow
) {
/*
* Add mechanism in backgrid to render different types of cells in
@@ -2314,125 +2314,5 @@ define([
},
});
Backgrid.Extension.SelectFileCell = Backgrid.Cell.extend({
/** @property */
className: 'file-cell',
defaults: {
supported_types: ['*'],
dialog_type: 'select_file',
dialog_title: gettext('Select file'),
type: 'text',
value: '',
placeholder: gettext('Select file...'),
disabled: false,
browse_btn_label: gettext('Select file'),
check_btn_label: gettext('Validate file'),
browse_btn_visible: true,
validate_btn_visible: true,
},
initialize: function() {
Backgrid.Cell.prototype.initialize.apply(this, arguments);
this.data = _.extend(this.defaults, this.column.toJSON());
},
template: _.template([
'<div class="input-group">',
'<input type="<%=type%>" id="<%=cId%>" class="form-control" value="<%-value%>" placeholder="<%-placeholder%>" <%=disabled ? "disabled" : ""%> />',
'<% if (browse_btn_visible) { %>',
'<div class="input-group-append">',
'<button class="btn btn-primary-icon fa fa-ellipsis-h select_item" <%=disabled ? "disabled" : ""%> aria-hidden="true" aria-label=<%=browse_btn_label%> title=<%=browse_btn_label%>></button>',
'</div>',
'<% } %>',
'<% if (validate_btn_visible) { %>',
'<div class="input-group-append">',
'<button class="btn btn-primary-icon fa fa-clipboard-check validate_item" <%=disabled ? "disabled" : ""%> <%=(value=="" || value==null) ? "disabled" : ""%> aria-hidden="true" aria-label=<%=check_btn_label%> title=<%=check_btn_label%>></button>',
'</div>',
'<% } %>',
'</div>',
].join('\n')),
events: {
'change input': 'onChange',
'click .select_item': 'onSelect',
'click .validate_item': 'onValidate',
},
render: function() {
this.$el.empty();
this.data = _.extend(this.data, {value: this.model.get(this.column.get('name'))});
// Adding unique id
this.data['cId'] = _.uniqueId('pgC_');
this.$el.append(this.template(this.data));
this.$input = this.$el.find('input');
this.delegateEvents();
return this;
},
onChange: function() {
var model = this.model,
column = this.column,
val = this.formatter.toRaw(this.$input.prop('value'), model);
model.set(column.get('name'), val);
},
onSelect: function() {
let self = this;
var params = {
supported_types: self.data.supported_types,
dialog_type: self.data.dialog_type,
dialog_title: self.data.dialog_title
};
pgAdmin.FileManager.init();
pgAdmin.FileManager.show_dialog(params);
// Listen click events of Storage Manager dialog buttons
this.listen_file_dlg_events();
},
storage_dlg_hander: function(value) {
var attrArr = this.column.get('name').split('.'),
name = attrArr.shift();
this.remove_file_dlg_event_listeners();
// Set selected value into the model
this.model.set(name, decodeURI(value));
},
storage_close_dlg_hander: function() {
this.remove_file_dlg_event_listeners();
},
listen_file_dlg_events: function() {
pgAdmin.Browser.Events.on('pgadmin-storage:finish_btn:' + this.data.dialog_type, this.storage_dlg_hander, this);
pgAdmin.Browser.Events.on('pgadmin-storage:cancel_btn:' + this.data.dialog_type, this.storage_close_dlg_hander, this);
},
remove_file_dlg_event_listeners: function() {
pgAdmin.Browser.Events.off('pgadmin-storage:finish_btn:' + this.data.dialog_type, this.storage_dlg_hander, this);
pgAdmin.Browser.Events.off('pgadmin-storage:cancel_btn:' + this.data.dialog_type, this.storage_close_dlg_hander, this);
},
onValidate: function() {
var model = this.model,
val = this.formatter.toRaw(this.$input.prop('value'), model);
if (_.isNull(val) || val.trim() === '') {
Notify.alert(gettext('Validate Path'), gettext('Path should not be empty.'));
}
$.ajax({
url: url_for('misc.validate_binary_path'),
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
'utility_path': val,
}),
})
.done(function(res) {
Notify.alert(gettext('Validate binary path'), gettext(res.data));
})
.fail(function(xhr, error) {
Notify.pgNotifier(error, xhr, gettext('Failed to validate binary path.'));
});
},
});
return Backgrid;
});

View File

@@ -199,7 +199,7 @@ PgIconButton.propTypes = {
export const PgButtonGroup = forwardRef(({children, ...props}, ref)=>{
/* Tooltip does not work for disabled items */
return (
<ButtonGroup disableElevation innerRef={ref} {...props}>
<ButtonGroup innerRef={ref} {...props}>
{children}
</ButtonGroup>
);

View File

@@ -35,13 +35,13 @@ import * as DateFns from 'date-fns';
import CodeMirror from './CodeMirror';
import gettext from 'sources/gettext';
import { showFileDialog } from '../helpers/legacyConnector';
import _ from 'lodash';
import { DefaultButton, PrimaryButton, PgIconButton } from './Buttons';
import CustomPropTypes from '../custom_prop_types';
import KeyboardShortcuts from './KeyboardShortcuts';
import QueryThresholds from './QueryThresholds';
import SelectThemes from './SelectThemes';
import { showFileManager } from '../helpers/showFileManager';
const useStyles = makeStyles((theme) => ({
@@ -326,11 +326,10 @@ FormInputDateTimePicker.propTypes = {
/* Use forwardRef to pass ref prop to OutlinedInput */
export const InputText = forwardRef(({
cid, helpid, readonly, disabled, value, onChange, controlProps, type, ...props }, ref) => {
cid, helpid, readonly, disabled, value, onChange, controlProps, type, size, ...props }, ref) => {
const maxlength = typeof(controlProps?.maxLength) != 'undefined' ? controlProps.maxLength : 255;
const classes = useStyles();
const patterns = {
'numeric': '^-?[0-9]\\d*\\.?\\d*$',
'int': '^-?[0-9]\\d*$',
@@ -356,12 +355,17 @@ export const InputText = forwardRef(({
finalValue = controlProps.formatter.fromRaw(finalValue);
}
const filteredProps = _.pickBy(props, (_v, key)=>(
/* When used in ButtonGroup, following props should be skipped */
!['color', 'disableElevation', 'disableFocusRipple', 'disableRipple'].includes(key)
));
return (
<OutlinedInput
ref={ref}
color="primary"
fullWidth
className={classes.formInput}
margin={size == 'small' ? 'dense' : 'none'}
inputProps={{
id: cid,
maxLength: controlProps?.multiline ? null : maxlength,
@@ -378,7 +382,7 @@ export const InputText = forwardRef(({
...(controlProps?.onKeyDown && { onKeyDown: controlProps.onKeyDown })
}
{...controlProps}
{...props}
{...filteredProps}
{...(['numeric', 'int'].indexOf(type) > -1 ? { type: 'tel' } : { type: type })}
/>
);
@@ -394,6 +398,7 @@ InputText.propTypes = {
onChange: PropTypes.func,
controlProps: PropTypes.object,
type: PropTypes.string,
size: PropTypes.string,
};
export function FormInputText({ hasError, required, label, className, helpMessage, testcid, ...props }) {
@@ -412,7 +417,6 @@ FormInputText.propTypes = {
testcid: PropTypes.string,
};
/* Using the existing file dialog functions using showFileDialog */
export function InputFileSelect({ controlProps, onChange, disabled, readonly, isvalidate = false, hideBrowseButton=false,validate, ...props }) {
const inpRef = useRef();
let textControlProps = {};
@@ -420,15 +424,24 @@ export function InputFileSelect({ controlProps, onChange, disabled, readonly, is
const {placeholder} = controlProps;
textControlProps = {placeholder};
}
const onFileSelect = (value) => {
onChange && onChange(decodeURI(value));
inpRef.current.focus();
const showFileDialog = ()=>{
let params = {
supported_types: controlProps.supportedTypes || [],
dialog_type: controlProps.dialogType || 'select_file',
dialog_title: controlProps.dialogTitle || '',
btn_primary: controlProps.btnPrimary || '',
};
showFileManager(params, (fileName)=>{
onChange && onChange(decodeURI(fileName));
inpRef.current.focus();
});
};
return (
<InputText ref={inpRef} disabled={disabled} readonly={readonly} onChange={onChange} controlProps={textControlProps} {...props} endAdornment={
<>
{!hideBrowseButton &&
<IconButton onClick={() => showFileDialog(controlProps, onFileSelect)}
<IconButton onClick={showFileDialog}
disabled={disabled || readonly} aria-label={gettext('Select a file')}><FolderOpenRoundedIcon /></IconButton>
}
{isvalidate &&
@@ -1184,6 +1197,9 @@ const useStylesFormFooter = makeStyles((theme) => ({
message: {
marginLeft: theme.spacing(0.5),
},
messageCenter: {
margin: 'auto',
},
closeButton: {
marginLeft: 'auto',
},
@@ -1272,13 +1288,13 @@ FormInputSelectThemes.propTypes = {
};
export function NotifierMessage({ type = MESSAGE_TYPE.SUCCESS, message, closable = true, onClose = () => {/*This is intentional (SonarQube)*/ } }) {
export function NotifierMessage({ type = MESSAGE_TYPE.SUCCESS, message, closable = true, showIcon=true, textCenter=false, onClose = () => {/*This is intentional (SonarQube)*/ } }) {
const classes = useStylesFormFooter();
return (
<Box className={clsx(classes.container, classes[`container${type}`])}>
<FormIcon type={type} className={classes[`icon${type}`]} />
<Box className={classes.message}>{HTMLReactParse(message || '')}</Box>
{showIcon && <FormIcon type={type} className={classes[`icon${type}`]} />}
<Box className={textCenter ? classes.messageCenter : classes.message}>{HTMLReactParse(message || '')}</Box>
{closable && <IconButton className={clsx(classes.closeButton, classes[`icon${type}`])} onClick={onClose}>
<FormIcon close={true} />
</IconButton>}
@@ -1290,6 +1306,8 @@ NotifierMessage.propTypes = {
type: PropTypes.oneOf(Object.values(MESSAGE_TYPE)).isRequired,
message: PropTypes.string,
closable: PropTypes.bool,
showIcon: PropTypes.bool,
textCenter: PropTypes.bool,
onClose: PropTypes.func,
};

View File

@@ -0,0 +1,88 @@
import React from 'react';
import ReactDataGrid from 'react-data-grid';
import { makeStyles } from '@material-ui/core';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import CustomPropTypes from '../custom_prop_types';
const useStyles = makeStyles((theme)=>({
root: {
height: '100%',
color: theme.palette.text.primary,
backgroundColor: theme.otherVars.qtDatagridBg,
fontSize: '12px',
border: 'none',
'--rdg-selection-color': theme.palette.primary.main,
'& .rdg-cell': {
...theme.mixins.panelBorder.right,
...theme.mixins.panelBorder.bottom,
fontWeight: 'abc',
'&[aria-colindex="1"]': {
padding: 0,
},
'&[aria-selected=true]:not([role="columnheader"])': {
outlineWidth: '0px',
outlineOffset: '0px',
}
},
'& .rdg-header-row .rdg-cell': {
padding: 0,
},
'& .rdg-header-row': {
backgroundColor: theme.palette.background.default,
fontWeight: 'normal',
},
'& .rdg-row': {
backgroundColor: theme.palette.background.default,
'&[aria-selected=true]': {
backgroundColor: theme.palette.primary.light,
color: theme.otherVars.qtDatagridSelectFg,
},
}
},
cellSelection: {
'& .rdg-cell': {
'&[aria-selected=true]:not([role="columnheader"])': {
outlineWidth: '1px',
outlineOffset: '-1px',
backgroundColor: theme.palette.primary.light,
color: theme.otherVars.qtDatagridSelectFg,
}
},
},
hasSelectColumn: {
'& .rdg-cell': {
'&[aria-selected=true][aria-colindex="1"]': {
outlineWidth: '2px',
outlineOffset: '-2px',
backgroundColor: theme.otherVars.qtDatagridBg,
color: theme.palette.text.primary,
}
},
'& .rdg-row[aria-selected=true] .rdg-cell:nth-child(1)': {
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
}
}
}));
export default function PgReactDataGrid({gridRef, className, hasSelectColumn=true, ...props}) {
const classes = useStyles();
let finalClassName = [classes.root];
hasSelectColumn && finalClassName.push(classes.hasSelectColumn);
props.enableCellSelect && finalClassName.push(classes.cellSelection);
finalClassName.push(className);
return <ReactDataGrid
ref={gridRef}
className={clsx(finalClassName)}
{...props}
/>;
}
PgReactDataGrid.propTypes = {
gridRef: CustomPropTypes.ref,
className: CustomPropTypes.className,
hasSelectColumn: PropTypes.bool,
enableCellSelect: PropTypes.bool,
};

View File

@@ -22,7 +22,7 @@ import CheckRoundedIcon from '@material-ui/icons/CheckRounded';
import { Rnd } from 'react-rnd';
import { ExpandDialogIcon, MinimizeDialogIcon } from '../components/ExternalIcon';
const ModalContext = React.createContext({});
export const ModalContext = React.createContext({});
const MIN_HEIGHT = 190;
const MIN_WIDTH = 500;

View File

@@ -1,65 +0,0 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2022, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
/* This file will have wrappers and connectors used by React components to
* re-use any existing non-react components.
* These functions may not be needed once all are migrated
*/
import gettext from 'sources/gettext';
import pgAdmin from 'sources/pgadmin';
import Notify from './Notifier';
export function confirmDeleteRow(onOK, onCancel, title, message) {
Notify.confirm(
title || gettext('Delete Row'),
message || gettext('Are you sure you wish to delete this row?'),
function() {
onOK();
return true;
},
function() {
onCancel();
return true;
}
);
}
/* Used by file select component to re-use existing logic */
export function showFileDialog(dialogParams, onFileSelect) {
let params = {
supported_types: dialogParams.supportedTypes || [],
dialog_type: dialogParams.dialogType || 'select_file',
dialog_title: dialogParams.dialogTitle || '',
btn_primary: dialogParams.btnPrimary || '',
};
pgAdmin.FileManager.init();
pgAdmin.FileManager.show_dialog(params);
const onFileSelectClose = (value)=>{
removeListeners();
onFileSelect(value);
};
const onDialogClose = ()=>removeListeners();
pgAdmin.Browser.Events.on('pgadmin-storage:finish_btn:' + params.dialog_type, onFileSelectClose);
pgAdmin.Browser.Events.on('pgadmin-storage:cancel_btn:' + params.dialog_type, onDialogClose);
const removeListeners = ()=>{
pgAdmin.Browser.Events.off('pgadmin-storage:finish_btn:' + params.dialog_type, onFileSelectClose);
pgAdmin.Browser.Events.off('pgadmin-storage:cancel_btn:' + params.dialog_type, onDialogClose);
};
}
export function onPgadminEvent(eventName, handler) {
pgAdmin.Browser.Events.on(eventName, handler);
}
export function offPgadminEvent(eventName, handler) {
pgAdmin.Browser.Events.off(eventName, handler);
}

View File

@@ -0,0 +1,6 @@
import pgAdmin from 'sources/pgadmin';
import 'pgadmin.tools.file_manager';
export function showFileManager(...args) {
pgAdmin.Tools.FileManager.show(...args);
}

View File

@@ -14,8 +14,6 @@ import pgAdmin from 'sources/pgadmin';
import { FileType } from 'react-aspen';
import { TreeNode } from './tree_nodes';
import { isValidData } from 'sources/utils';
function manageTreeEvents(event, eventName, item) {
let d = item ? item._metadata.data : [];
let node_metadata = item ? item._metadata : {};
@@ -594,6 +592,6 @@ export function findInTree(rootNode, path) {
})(rootNode);
}
let isValidTreeNodeData = isValidData;
let isValidTreeNodeData = (data) => (!_.isEmpty(data));
export { isValidTreeNodeData };

View File

@@ -7,7 +7,7 @@
//
//////////////////////////////////////////////////////////////////////////
import _ from 'underscore';
import _ from 'lodash';
import $ from 'jquery';
import gettext from 'sources/gettext';
import 'wcdocker';
@@ -115,14 +115,6 @@ export function findAndSetFocus(container) {
}, 200);
}
let isValidData = (data) => (!_.isUndefined(data) && !_.isNull(data));
let isFunction = (fn) => (_.isFunction(fn));
let isString = (str) => (_.isString(str));
export {
isValidData, isFunction, isString,
};
export function getEpoch(inp_date) {
let date_obj = inp_date ? inp_date : new Date();
return parseInt(date_obj.getTime()/1000);
@@ -456,6 +448,10 @@ export function getBrowser() {
tem=/\brv[ :]+(\d+)/g.exec(ua) || [];
return {name:'IE', version:(tem[1]||'')};
}
if(ua.startsWith('Nwjs')) {
let nwjs = ua.split('-')[0]?.split(':');
return {name:nwjs[0], version: nwjs[1]};
}
if(M[1]==='Chrome') {
tem=ua.match(/\bOPR|Edge\/(\d+)/);
@@ -480,3 +476,21 @@ export function checkTrojanSource(content, isPasteEvent) {
Notify.alert(gettext('Trojan Source Warning'), msg);
}
}
export function downloadBlob(blob, fileName) {
let urlCreator = window.URL || window.webkitURL,
downloadUrl = urlCreator.createObjectURL(blob),
link = document.createElement('a');
document.body.appendChild(link);
if (getBrowser() === 'IE' && window.navigator.msSaveBlob) {
// IE10+ : (has Blob, but not a[download] or URL)
window.navigator.msSaveBlob(blob, fileName);
} else {
link.setAttribute('href', downloadUrl);
link.setAttribute('download', fileName);
link.click();
}
document.body.removeChild(link);
}