Commit 7ed6e0b2 authored by Matija Obreza's avatar Matija Obreza
Browse files

Merge branch '185-bulk-manage-repository-metadata-for-selected-folder' into 'master'

Resolve "Bulk manage repository metadata for selected folder"

Closes #185

See merge request genesys-pgr/genesys-ui!215
parents 9c4a4823 79180192
......@@ -1570,6 +1570,7 @@
"readPDCI": "Read about Passport Data Completeness Index",
"pdciScore": "Average PDCI score for {{count, number}} accessions is {{avg, number}}, with minimum score of {{min, number}} and maximum score of {{max, number}}.",
"browseAccessions": "Browse accessions",
"browseFiles": "Browse files",
"MCPD": "MCPD",
"PDCI_short": "PDCI",
"zip": "ZIP"
......@@ -1580,6 +1581,10 @@
"p": {
"edit": {
"title": "Edit {{instName}}"
},
"repository": {
"noFolder": "No folder for {{code}}",
"noAccessRights": "You have no access rights for {{code}} files"
}
},
"c": {
......@@ -1610,6 +1615,7 @@
"instDetails": "{{instCode,string}} details"
}
}
,"kpi": {
"admin": {
"c": {
......@@ -1846,7 +1852,10 @@
"createGalleryAlert": "Image gallery at {{folderPath, string}} could not be created.",
"deleteFolder": "Delete folder",
"deleteFolderAlert": "Folder {{folderPath, string}} could not be deleted.",
"title": "File repository"
"title": "File repository",
"downloadFolderMetadata": "Download metadata",
"uploadFolderMetadata": "Upload metadata",
"uploadFailed": "Some of files weren't loaded"
}
},
"dialog": {
......@@ -1908,6 +1917,7 @@
}
}
}
,"requests": {
"public": {
"p": {
......
......@@ -19,6 +19,16 @@ const publicRoutes = [
];
const dashboardRoutes = [
{
path: ':root(/wiews/[a-zA-Z]+[0-9]+/)files/:path(.*)',
component: Loadable({
loader: () => import(/* webpackMode:"lazy", webpackChunkName: "repository" */'institutes/ui/RepositoryPage'),
}),
exact: true,
extraProps: {
title: 'Institute file repository',
},
},
{
path: '/wiews/:wiewsCode([a-zA-Z]+[0-9]+)/edit',
component: Loadable({
......
......@@ -30,6 +30,7 @@
"readPDCI": "Read about Passport Data Completeness Index",
"pdciScore": "Average PDCI score for {{count, number}} accessions is {{avg, number}}, with minimum score of {{min, number}} and maximum score of {{max, number}}.",
"browseAccessions": "Browse accessions",
"browseFiles": "Browse files",
"MCPD": "MCPD",
"PDCI_short": "PDCI",
"zip": "ZIP"
......@@ -40,6 +41,10 @@
"p": {
"edit": {
"title": "Edit {{instName}}"
},
"repository": {
"noFolder": "No folder for {{code}}",
"noAccessRights": "You have no access rights for {{code}} files"
}
},
"c": {
......@@ -69,4 +74,4 @@
"accessionsInGenesys": "Accessions in Genesys",
"instDetails": "{{instCode,string}} details"
}
}
\ No newline at end of file
}
......@@ -105,7 +105,7 @@ class DisplayPage extends React.Component<IDisplayPageProps, any> {
}
public render() {
const { error, loading, institute, code, mapLayers, t } = this.props;
const { error, loading, institute, code, userRoles, mapLayers, t } = this.props;
const slug: string = this.state.authenticated ? 'download-authenticated' : 'download-anonymous';
let cropShortNameOverview;
......@@ -163,6 +163,11 @@ class DisplayPage extends React.Component<IDisplayPageProps, any> {
<CardActions className="container-spacing-vertical mt-15">
<ButtonBar barLabelText={ t('institutes.public.p.display.actions') }>
<Button onClick={ this.applyInstituteCodeFilter }>{ t('institutes.public.p.display.browseAccessions') }</Button>
{ (userRoles.findIndex((role) => role === 'ROLE_ADMINISTRATOR') !== -1 || institute.details._permissions.manage) &&
<Link to={ `/dashboard/wiews/${institute.details.code}/files/` }>
<Button>{ t('institutes.public.p.display.browseFiles') }</Button>
</Link>
}
<Button onClick={ this.applyFilterForOverview }>{ t('accessions.tab.overview') }</Button>
{ this.state.authenticated &&
<DownloadDialog downloadUrl={ `/proxy/api/v1/wiews/${code}/download` }
......
import * as React from 'react';
import {connect} from 'react-redux';
import {translate} from 'react-i18next';
import {bindActionCreators} from 'redux';
import {normalize} from 'path';
// actions
import {getFolder} from 'repository/actions/admin';
import navigateTo from 'actions/navigation';
import {showSnackbar} from 'actions/snackbar';
// ui
import RepositoryBrowser from 'repository/ui/RepositoryBrowser';
class RepositoryPage extends React.Component<any> {
public componentWillMount(): void {
const {folder, folderPath, getFolder, root, error, loading, navigateTo, showSnackbar, t} = this.props;
if (!folder) {
return getFolder(folderPath);
}
if (!loading) {
if (error && error.status === 404) {
const code = root.replace('/wiews/', '').replace('/', '');
showSnackbar(t('institutes.dashboard.p.repository.noFolder', {code}));
return navigateTo(`/wiews/${code}`);
}
if (folder && folder.folder && !folder.folder._permissions.read) {
const code = root.replace('/wiews/', '').replace('/', '');
showSnackbar(t('institutes.dashboard.p.repository.noAccessRights', {code}));
return navigateTo(`/wiews/${code}`);
}
}
}
public componentWillReceiveProps(nextProps, nextContext) {
const {folder, root, error, loading, navigateTo, showSnackbar, t} = nextProps;
if (!loading) {
if (error && error.status === 404) {
const code = root.replace('/wiews/', '').replace('/', '');
showSnackbar(t('institutes.dashboard.p.repository.noFolder', {code}));
return navigateTo(`/wiews/${code}`);
}
if (folder && folder.folder && !folder.folder._permissions.read) {
const code = root.replace('/wiews/', '').replace('/', '');
showSnackbar(t('institutes.dashboard.p.repository.noAccessRights', {code}));
return navigateTo(`/wiews/${code}`);
}
}
}
public render(): React.ReactNode {
return <RepositoryBrowser { ...this.props }/>;
}
}
const mapStateToProps = (state, ownProps) => ({
root: ownProps.match.params.root || ownProps.route.root || '/',
path: ownProps.match.params.path || '/',
folderPath: normalize((ownProps.match.params.root || ownProps.route.root || '/') + (ownProps.match.params.path || '/')).replace(/^(.+)\/$/, '$1'),
folder: state.repository.admin.folder ? state.repository.admin.folder.data : undefined,
loading: state.repository.admin.folder ? state.repository.admin.folder.loading : true,
error: state.repository.admin.folder ? state.repository.admin.folder.error : undefined,
});
const mapDispatchToProps = (dispatch) => bindActionCreators({
getFolder,
navigateTo,
showSnackbar,
}, dispatch);
export default translate()(connect(mapStateToProps, mapDispatchToProps)(RepositoryPage));
import { normalize } from 'path';
// Actions
import {createApiCaller, createPureApiCaller} from 'actions/ApiCall';
import {showSnackbar} from 'actions/snackbar';
// Constants
import {
......@@ -43,6 +44,7 @@ const apiLoadGallery = createApiCaller(RepositoryService.getGallery, RECEIVE_IMA
const apiLoadFile = createApiCaller(RepositoryService.getFile, RECEIVE_FILE);
const apiCreateGallery = createApiCaller(RepositoryService.createGallery, RECEIVE_IMAGE_GALLERY);
const apiDeleteGallery = createApiCaller(RepositoryService.removeGallery, REMOVE_IMAGE_GALLERY);
const apiUploadFolderMetadata = createApiCaller(RepositoryService.uploadFolderMetadata, RECEIVE_FILES);
// Get folder details
......@@ -91,7 +93,24 @@ const goToFolder = (folder: RepositoryFolder | string) => (dispatch) => {
// Upload file
export const uploadFile = (path: string, file: File) => (dispatch, getState) => {
return dispatch(apiUploadFile(normalize(path), file))
.then((file: RepositoryFile) => dispatch(getFolder(file.folder.path)));
.then((file: RepositoryFile) => dispatch(getFolder(`${file.folder}`)));
};
export const uploadFiles = (path: string, files: File[]) => (dispatch) => {
const promises = files.map((file) => RepositoryService.uploadFile(normalize(path), file));
return Promise.all(promises)
.then(() => dispatch(getFolder(path)))
.catch((e) => {
console.log(e);
dispatch(getFolder(path));
dispatch(showSnackbar('repository.admin.p.repositoryBrowser.uploadFailed'));
});
};
// Upload folder metadata
export const uploadFolderMetadata = (path: string, file: File) => (dispatch, getState) => {
return dispatch(apiUploadFolderMetadata(normalize(path), file));
};
// Image gallery
......
......@@ -34,7 +34,7 @@ export default function reducer(state = INITIAL_STATE, action: IReducerAction =
const {apiCall} = action.payload;
return !state.folder.data || !state.folder.data.subFolders || !apiCall.data || apiCall.data.number === 0 ? update(state, {
folder: {subFolders: {$set: apiCall.data}},
folder: {data: {subFolders: {$set: apiCall.data}}},
}) :
update(state, {
folder: {
......@@ -49,7 +49,8 @@ export default function reducer(state = INITIAL_STATE, action: IReducerAction =
const {apiCall} = action.payload;
return !state.folder.data || !state.folder.data.files || !apiCall.data || apiCall.data.number === 0 ? update(state, {
folder: {files: {$set: apiCall.data}},
folder: {data: {files: {$set: apiCall.data}}},
file: {$set: null},
}) :
update(state, {
folder: {
......@@ -57,6 +58,7 @@ export default function reducer(state = INITIAL_STATE, action: IReducerAction =
files: {$set: FilteredPage.merge(state.folder && state.folder.data && state.folder.data.files, apiCall.data)},
},
},
file: {$set: null},
});
}
......@@ -79,18 +81,21 @@ export default function reducer(state = INITIAL_STATE, action: IReducerAction =
case UPDATE_FILE_LIST: {
const { apiCall } = action.payload;
const files: RepositoryFile[] = [];
if (state.folder && state.folder.data && state.folder.data.files) {
state.folder.data.files.content.forEach((item) => {
if (apiCall.data && item.uuid !== apiCall.data.uuid) {
files.push(item);
}
});
let files: RepositoryFile[];
if (state.folder && state.folder.data && state.folder.data.files && apiCall.data) {
files = state.folder.data.files.content.filter((item) => item.uuid !== apiCall.data.uuid);
} else {
return state;
}
return update(state, {
folder: {
files: {
$set: files,
data: {
files: {
content: {
$set: files,
},
},
},
},
});
......
......@@ -16,7 +16,10 @@
"createGalleryAlert": "Image gallery at {{folderPath, string}} could not be created.",
"deleteFolder": "Delete folder",
"deleteFolderAlert": "Folder {{folderPath, string}} could not be deleted.",
"title": "File repository"
"title": "File repository",
"downloadFolderMetadata": "Download metadata",
"uploadFolderMetadata": "Upload metadata",
"uploadFailed": "Some of files weren't loaded"
}
},
"dialog": {
......@@ -77,4 +80,4 @@
}
}
}
}
\ No newline at end of file
}
......@@ -4,7 +4,7 @@ import { translate } from 'react-i18next';
import { bindActionCreators } from 'redux';
import { normalize } from 'path';
import { getFolder, uploadFile, deleteFolder, createGallery, removeFile, loadMoreFiles, loadMoreFolders, toggleFolderDialog } from 'repository/actions/admin';
import {getFolder, uploadFiles, uploadFolderMetadata, deleteFolder, createGallery, removeFile, loadMoreFiles, loadMoreFolders, toggleFolderDialog} from 'repository/actions/admin';
import FolderDetails from 'model/repository/FolderDetails';
// import RepositoryFile from 'model/repository/RepositoryFile';
......@@ -33,6 +33,7 @@ import Page from 'model/Page';
import CreateNewButton from 'ui/common/buttons/CreateNewButton';
import FolderIcon from '@material-ui/icons/CreateNewFolder';
import GalleryIcon from '@material-ui/icons/InsertPhoto';
import UploadButton from 'ui/common/buttons/UploadButton';
interface IRepositoryBrowserProps extends React.ClassAttributes<any> {
t?: any;
......@@ -41,8 +42,11 @@ interface IRepositoryBrowserProps extends React.ClassAttributes<any> {
path: string;
folderPath: string;
folder: FolderDetails;
loading: boolean;
error: any;
getFolder: (path: string) => any;
uploadFile: (path: string, file: File) => any;
uploadFiles: (path: string, files: File[]) => any;
uploadFolderMetadata: (path: string, file: File) => any;
deleteFolder: (path: string) => any;
removeFile: (uuid: string) => any;
loadMoreFiles: (path: string, paged: Page<RepositoryFile>) => any;
......@@ -65,21 +69,26 @@ class RepositoryBrowser extends React.Component<IRepositoryBrowserProps, any> {
}
public componentWillReceiveProps(nextProps) {
const { path, folder, folderPath } = nextProps;
const {path, folder, folderPath, error, loading} = nextProps;
const { getFolder } = this.props;
// console.log(`Props root=${root} path=${path} folderPath=${folderPath} at=${folder && folder.folder && folder.folder.path}`, oldFolder);
if (!folder || (!folder.folder && path && path !== '/') || (folder.folder && folder.folder.path !== folderPath)) {
if (!error && !loading && (!folder || (!folder.folder && path && path !== '/') || (folder.folder && folder.folder.path !== folderPath))) {
// console.log(`Loading folder root=${root} path=${path} ${folderPath}`);
getFolder(folderPath);
}
}
protected handleUploading = (files: File[]) => {
const { uploadFile, folderPath } = this.props;
for (const item of files) {
uploadFile(folderPath, item);
const {uploadFiles, folderPath} = this.props;
uploadFiles(folderPath, [...files]);
}
protected handleUploadingMetadata = (files: File[]) => {
const { folderPath, uploadFolderMetadata } = this.props;
if (files && files.length > 0) {
uploadFolderMetadata(folderPath, files[0]);
}
}
......@@ -146,14 +155,12 @@ class RepositoryBrowser extends React.Component<IRepositoryBrowserProps, any> {
public render() {
const { folder, root, path, t } = this.props;
const {folder, loading, error, root, path, folderPath, t} = this.props;
const parentFolder = new RepositoryFolder();
parentFolder.path = '..';
parentFolder.name = '..';
const stillLoading: boolean = !folder;
const createActions = [];
if (folder && (!folder.folder || (folder.folder && folder.folder._permissions.create))) {
......@@ -164,7 +171,7 @@ class RepositoryBrowser extends React.Component<IRepositoryBrowserProps, any> {
});
}
if (folder && folder.folder && !folder.gallery) {
if (folder && folder.folder && !folder.gallery && folder.folder._permissions.manage) {
createActions.push({
title: 'repository.admin.p.repositoryBrowser.gallery',
action: () => this.createGallery(),
......@@ -173,11 +180,21 @@ class RepositoryBrowser extends React.Component<IRepositoryBrowserProps, any> {
}
return (
stillLoading ? <Loading /> : (
loading ? <Loading/> : (
<div>
<PageTitle title={ t('repository.admin.p.repositoryBrowser.title') }/>
<ContentHeaderWithButton isSecondary title={ <FolderCrumbs root={ root } path={ path } /> } buttons={
<ContentHeaderWithButton isSecondary title={ <FolderCrumbs root={ root } path={ path }/> } buttons={ folder &&
<span>
{ folder.folder && folder.folder._permissions.manage &&
<a href={ `/proxy/api/v1/repository/download/folder-metadata/${folderPath}` }>
<Button key="downloadm" variant="contained">{ t('repository.admin.p.repositoryBrowser.downloadFolderMetadata') }</Button>
</a> }
{ folder.folder && folder.folder._permissions.manage &&
<UploadButton
id="metadata-file"
title="repository.admin.p.repositoryBrowser.uploadFolderMetadata"
handleUploading={ this.handleUploadingMetadata }
/> }
{ ! folder.folder ? null :
folder.gallery ? <Button key="viewg" variant="contained" onClick={ this.goToGallery }>{ t('repository.admin.p.repositoryBrowser.viewGallery') }</Button> : null }
{ folder.folder && folder.folder._permissions.delete && <Button onClick={ this.deleteFolder } key="deletef" variant="contained">{ t('repository.admin.p.repositoryBrowser.deleteFolder') }</Button> }
......@@ -185,22 +202,26 @@ class RepositoryBrowser extends React.Component<IRepositoryBrowserProps, any> {
{ folder.folder && folder.folder._permissions.manage && <Permissions clazz={ RepositoryFolder.clazz } id={ folder.folder.id } variant="contained" /> }
</span>
} />
<PageContents className={ `container-spacing-horizontal pt-1rem` }>
<div>
{ folder.folder && root !== `${folder.folder.path}/` && <FolderCard compact key="parent" folder={ parentFolder } /> }
<PagedLoader paged={ folder.subFolders } itemRenderer={ this.renderSubfolder } loadMore={ this.loadMoreFolders } />
</div>
<Grid item>
<Grid container spacing={ 16 }>
<PagedLoader paged={ folder.files } itemRenderer={ this.renderFile } loadMore={ this.loadMoreFiles } />
<PageContents>
{ error && error.data && error.data.error ||
<div className={ `container-spacing-horizontal pt-1rem` }>
<div>
{ folder.folder && root !== `${folder.folder.path}/` && <FolderCard compact key="parent" folder={ parentFolder }/> }
<PagedLoader paged={ folder.subFolders } itemRenderer={ this.renderSubfolder } loadMore={ this.loadMoreFolders }/>
</div>
<Grid item>
<Grid container spacing={ 16 }>
<PagedLoader paged={ folder.files } itemRenderer={ this.renderFile } loadMore={ this.loadMoreFiles }/>
</Grid>
</Grid>
</Grid>
<div>
<FileUploader handleUploading={ this.handleUploading }/>
<div>
<FileUploader id="repository-file" handleUploading={ this.handleUploading }/>
</div>
</div>
}
</PageContents>
<CreateNewButton actions={ createActions }/>
{ (! folder.folder || (folder.folder && folder.folder._permissions.create)) && <CreateFolderDialog root={ root } path={ path } /> }
{ createActions.length > 0 && <CreateNewButton actions={ createActions }/> }
{ (folder && (!folder.folder || (folder.folder && folder.folder._permissions.create))) && <CreateFolderDialog root={ root } path={ path } /> }
</div>
)
);
......@@ -208,18 +229,20 @@ class RepositoryBrowser extends React.Component<IRepositoryBrowserProps, any> {
}
const mapStateToProps = (state, ownProps) => ({
root: ownProps.route.root || '/',
root: ownProps.match.params.root || ownProps.route.root || '/',
path: ownProps.match.params.path || '/',
folderPath: normalize((ownProps.route.root || '/') + (ownProps.match.params.path || '/')).replace(/^(.+)\/$/, '$1'),
folderPath: normalize((ownProps.match.params.root || ownProps.route.root || '/') + (ownProps.match.params.path || '/')).replace(/^(.+)\/$/, '$1'),
folder: state.repository.admin.folder ? state.repository.admin.folder.data : undefined,
loading: state.repository.admin.folder ? state.repository.admin.folder.loading : true,
error: state.repository.admin.folder ? state.repository.admin.folder.error : undefined,
});
const mapDispatchToProps = (dispatch) => bindActionCreators({
getFolder,
loadMoreFiles,
loadMoreFolders,
uploadFile,
uploadFiles,
uploadFolderMetadata,
deleteFolder,
removeFile,
createGallery,
......
......@@ -27,6 +27,7 @@ const URL_GET_GALLERY = UrlTemplate.parse(`/api/v1/repository/gallery`);
const URL_CREATE_GALLERY = UrlTemplate.parse(`/api/v1/repository/gallery`);
const URL_REMOVE_GALLERY = UrlTemplate.parse(`/api/v1/repository/gallery`);
const URL_UPLOAD_FILE = UrlTemplate.parse(`/api/v1/repository/upload`);
const URL_UPLOAD_FOLDER_METADATA = UrlTemplate.parse(`/api/v1/repository/upload/folder-metadata`);
/*
* Defined in Swagger as 'repository'
......@@ -361,6 +362,28 @@ class FileRepositoryService {
}).then(({ data }) => data as RepositoryFile);
}
/**
* uploadFolderMetadata at /api/v1/repository/upload/folder-metadata
*
* @param file file
*/
public static uploadFolderMetadata(path: string, file: File, xhrConfig?): Promise<Page<RepositoryFile>> {
const apiUrl = URL_UPLOAD_FOLDER_METADATA.expand({}) + path;
// console.log(`Fetching from ${apiUrl}`);
const data = new FormData();
data.append('file', file);
const content = { data };
return axiosBackend.request({
...xhrConfig,
url: apiUrl,
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' },
...content,
}).then(({ data }) => data as Page<RepositoryFile>);
}
}
export default FileRepositoryService;
......@@ -6,6 +6,7 @@ interface IUploadButtonProps extends React.ClassAttributes<any> {
handleUploading: (files: File[]) => void;
variant?: 'text' | 'outlined' | 'contained' | 'contained' | 'fab' | 'extendedFab';
title?: string;
id?: any;
multiple?: boolean;
style?: any;
t: any;
......@@ -21,20 +22,20 @@ class UploadButton extends React.Component<IUploadButtonProps, any> {
}
public render() {
const { title = 'common:action.chooseFile', variant = 'contained', style, multiple = false, t } = this.props;
const { id = 'file', title = 'common:action.chooseFile', variant = 'contained', style, multiple = false, t } = this.props;
return (
<div style={ style }>
<span style={ style }>
<input
id="file"
id={ id }
type="file"
style={ { display: 'none' } }
multiple={ multiple }
onChange={ this.upload }
/>
<label htmlFor="file">
<label htmlFor={ id }>
<Button variant={ variant } component="span">{ t(title) }</Button>
</label>
</div>
</span>
);
}
}
......
......@@ -16,6 +16,7 @@ const styles = () => ({
});
export interface ITargetBoxProps extends React.ClassAttributes<any> {
id: any;
accepts: string[];
connectDropTarget?: ConnectDropTarget;
isOver?: boolean;
......@@ -41,7 +42,7 @@ class TargetBox extends React.Component<ITargetBoxProps, any> {
}
public render() {
const { canDrop, isOver, connectDropTarget, classes, handleUploading, t } = this.props;
const { id = 'file', canDrop, isOver, connectDropTarget, classes, handleUploading, t } = this.props;
const isActive = canDrop && isOver;
return (
......@@ -50,10 +51,12 @@ class TargetBox extends React.Component<ITargetBoxProps, any> {
{ isActive ? t('common:fileUploader.release')
:
<div>
<div>
<div className="mb-5">
{ t('common:fileUploader.dragFiles') }
</div>
<UploadButton multiple
<UploadButton
id={ id }
multiple
style={ { marginTop: '5px' } }
title={ t('common:fileUploader.chooseFiles') }