Merge branch 'master' into 14565-admin-managing-user
authorPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Tue, 18 Dec 2018 09:09:50 +0000 (10:09 +0100)
committerPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Tue, 18 Dec 2018 09:09:50 +0000 (10:09 +0100)
refs #14565

Arvados-DCO-1.1-Signed-off-by: Pawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>

14 files changed:
README.md
public/file-viewers-example.json [new file with mode: 0644]
src/common/config.ts
src/components/context-menu/context-menu.tsx
src/components/icon/icon.tsx
src/index.tsx
src/models/file-viewers-config.ts [new file with mode: 0644]
src/services/collection-service/collection-service.ts
src/services/file-viewers-config-service/file-viewers-config-service.ts [new file with mode: 0644]
src/services/services.ts
src/store/file-viewers/file-viewers-actions.ts [new file with mode: 0644]
src/store/file-viewers/file-viewers-selectors.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
src/views-components/context-menu/actions/file-viewer-actions.tsx [new file with mode: 0644]

index e8d77701107179a6ef88fcfb68cd47d857a321a9..425d1787848e5b9dad4e077eed23b2f73aea47ea 100644 (file)
--- a/README.md
+++ b/README.md
@@ -41,7 +41,8 @@ Currently this configuration schema is supported:
 ```
 {
     "API_HOST": "string",
-    "VOCABULARY_URL": "string"
+    "VOCABULARY_URL": "string",
+    "FILE_VIEWERS_CONFIG_URL": "string",
 }
 ```
 
@@ -49,6 +50,13 @@ Currently this configuration schema is supported:
 Local path, or any URL that allows cross-origin requests. See 
 [Vocabulary JSON file example](public/vocabulary-example.json).
 
+### FILE_VIEWERS_CONFIG_URL
+Local path, or any URL that allows cross-origin requests. See:
+
+[File viewers config file example](public/file-viewers-example.json)
+
+[File viewers config scheme](src/models/file-viewers-config.ts)
+
 ### Licensing
 
 Arvados is Free Software. See COPYING for information about Arvados Free
diff --git a/public/file-viewers-example.json b/public/file-viewers-example.json
new file mode 100644 (file)
index 0000000..27adb70
--- /dev/null
@@ -0,0 +1,25 @@
+[
+    {
+        "name": "File browser",
+        "extensions": [
+            ".txt",
+            ".zip"
+        ],
+        "url": "https://doc.arvados.org",
+        "filePathParam": "filePath",
+        "iconUrl": "https://material.io/tools/icons/static/icons/baseline-next_week-24px.svg"
+    },
+    {
+        "name": "Collection browser",
+        "extensions": [],
+        "collections": true,
+        "url": "https://doc.arvados.org",
+        "filePathParam": "collectionPath"
+    },
+    {
+        "name": "Universal browser",
+        "collections": true,
+        "url": "https://doc.arvados.org",
+        "filePathParam": "filePath"
+    }
+]
\ No newline at end of file
index b7b89bd9e4930100188725b7f117f67abf076587..db67ed8dceec9fc06837945cba4a820682a642af 100644 (file)
@@ -50,6 +50,7 @@ export interface Config {
     websocketUrl: string;
     workbenchUrl: string;
     vocabularyUrl: string;
+    fileViewersConfigUrl: string;
 }
 
 export const fetchConfig = () => {
@@ -59,10 +60,15 @@ export const fetchConfig = () => {
         .catch(() => Promise.resolve(getDefaultConfig()))
         .then(config => Axios
             .get<Config>(getDiscoveryURL(config.API_HOST))
-            .then(response => ({ 
+            .then(response => ({
                 // TODO: After tests delete `|| '/vocabulary-example.json'`
-                config: {...response.data, vocabularyUrl: config.VOCABULARY_URL || '/vocabulary-example.json' }, 
-                apiHost: config.API_HOST, 
+                // TODO: After tests delete `|| '/file-viewers-example.json'`
+                config: {
+                    ...response.data,
+                    vocabularyUrl: config.VOCABULARY_URL || '/vocabulary-example.json',
+                    fileViewersConfigUrl: config.FILE_VIEWERS_CONFIG_URL || '/file-viewers-example.json'
+                },
+                apiHost: config.API_HOST,
             })));
 
 };
@@ -111,17 +117,20 @@ export const mockConfig = (config: Partial<Config>): Config => ({
     websocketUrl: '',
     workbenchUrl: '',
     vocabularyUrl: '',
+    fileViewersConfigUrl: '',
     ...config
 });
 
 interface ConfigJSON {
     API_HOST: string;
     VOCABULARY_URL: string;
+    FILE_VIEWERS_CONFIG_URL: string;
 }
 
 const getDefaultConfig = (): ConfigJSON => ({
     API_HOST: process.env.REACT_APP_ARVADOS_API_HOST || "",
     VOCABULARY_URL: "",
+    FILE_VIEWERS_CONFIG_URL: "",
 });
 
 const getDiscoveryURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/discovery/v1/apis/arvados/v1/rest`;
index 4068251bdc04c82487cbf141fbdea910692496a8..98456dad51369c8efd240cb4249cbd734ba85de5 100644 (file)
@@ -53,7 +53,11 @@ export class ContextMenu extends React.PureComponent<ContextMenuProps> {
                                             {item.name}
                                         </ListItemText>}
                                 </ListItem>)}
-                        {groupIndex < items.length - 1 && <Divider />}
+                        {
+                            items[groupIndex + 1] &&
+                            items[groupIndex + 1].length > 0 &&
+                            <Divider />
+                        }
                     </React.Fragment>)}
             </List>
         </Popover>;
index c077f7a675ceaf10662c20e885160050fd5d10a9..2bd16970614aab64c2de5d5306fc90e15e6a5de9 100644 (file)
@@ -38,6 +38,7 @@ import MoreVert from '@material-ui/icons/MoreVert';
 import Mail from '@material-ui/icons/Mail';
 import MoveToInbox from '@material-ui/icons/MoveToInbox';
 import Notifications from '@material-ui/icons/Notifications';
+import OpenInNew from '@material-ui/icons/OpenInNew';
 import People from '@material-ui/icons/People';
 import Person from '@material-ui/icons/Person';
 import PersonAdd from '@material-ui/icons/PersonAdd';
@@ -83,6 +84,7 @@ export const MoreOptionsIcon: IconType = (props) => <MoreVert {...props} />;
 export const MoveToIcon: IconType = (props) => <Input {...props} />;
 export const NewProjectIcon: IconType = (props) => <CreateNewFolder {...props} />;
 export const NotificationIcon: IconType = (props) => <Notifications {...props} />;
+export const OpenIcon: IconType = (props) => <OpenInNew {...props} />;
 export const OutputIcon: IconType = (props) => <MoveToInbox {...props} />;
 export const PaginationDownIcon: IconType = (props) => <ArrowDropDown {...props} />;
 export const PaginationLeftArrowIcon: IconType = (props) => <ChevronLeft {...props} />;
index 508fa7c3dad5cb140246352cb821b56c98d49d7a..7cd4ae9ab45dfc37e8b8bdc9690318ba393fc530 100644 (file)
@@ -59,6 +59,7 @@ import { apiClientAuthorizationActionSet } from '~/views-components/context-menu
 import { groupActionSet } from '~/views-components/context-menu/action-sets/group-action-set';
 import { groupMemberActionSet } from '~/views-components/context-menu/action-sets/group-member-action-set';
 import { linkActionSet } from '~/views-components/context-menu/action-sets/link-action-set';
+import { loadFileViewersConfig } from '~/store/file-viewers/file-viewers-actions';
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -106,6 +107,7 @@ fetchConfig()
         store.dispatch(setCurrentTokenDialogApiHost(apiHost));
         store.dispatch(setUuidPrefix(config.uuidPrefix));
         store.dispatch(loadVocabulary);
+        store.dispatch(loadFileViewersConfig);
 
         const TokenComponent = (props: any) => <ApiToken authService={services.authService} {...props} />;
         const MainPanelComponent = (props: any) => <MainPanel {...props} />;
diff --git a/src/models/file-viewers-config.ts b/src/models/file-viewers-config.ts
new file mode 100644 (file)
index 0000000..e95116b
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type FileViewerList = FileViewer[];
+
+export interface FileViewer {
+    /**
+     * Name is used as a label in file's context menu
+     */
+    name: string;
+
+    /**
+     * Limits files for which viewer is enabled
+     * If not given, viewer will be enabled for all files
+     * Viewer is enabled if file name ends with an extension.
+     * 
+     * Example: `['.zip', '.tar.gz', 'bam']`
+     */
+    extensions?: string[];
+
+    /**
+     * Determines whether a viewer is enabled for collections.
+     */
+    collections?: boolean;
+
+    /**
+     * URL that redirects to a viewer 
+     * Example: `https://bam-viewer.com`
+     */
+    url: string;
+
+    /**
+     * Name of a search param that will be used to send file's path to a viewer
+     * Example: 
+     * 
+     * `{ filePathParam: 'filePath' }`
+     * 
+     * `https://bam-viewer.com?filePath=/path/to/file`
+     */
+    filePathParam: string;
+
+    /**
+     * Icon that will display next to a label
+     */
+    iconUrl?: string;
+}
index b0d5cb1445db854e8d88d43e10c95a6b59e80569..f0f25a2d7ac0e82a49eb8bfae34474121e414f5e 100644 (file)
@@ -56,7 +56,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
             : this.webdavClient.defaults.baseURL;
         return {
             ...file,
-            url: baseUrl + file.url + '?api_token=' + this.authService.getApiToken()
+            url: baseUrl + file.url
         };
     }
 
diff --git a/src/services/file-viewers-config-service/file-viewers-config-service.ts b/src/services/file-viewers-config-service/file-viewers-config-service.ts
new file mode 100644 (file)
index 0000000..4029225
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import Axios from 'axios';
+import { FileViewerList } from '~/models/file-viewers-config';
+
+export class FileViewersConfigService {
+    constructor(
+        private url: string
+    ) { }
+
+    get() {
+        return Axios
+            .get<FileViewerList>(this.url)
+            .then(response => response.data);
+    }
+}
index 59fe2d4704ee194e6a5f0cf684c09b3629342ada..78ea714b93cb272afcf8c6518dfd621ed64d9d9e 100644 (file)
@@ -30,6 +30,7 @@ import { RepositoriesService } from '~/services/repositories-service/repositorie
 import { AuthorizedKeysService } from '~/services/authorized-keys-service/authorized-keys-service';
 import { VocabularyService } from '~/services/vocabulary-service/vocabulary-service';
 import { NodeService } from '~/services/node-service/node-service';
+import { FileViewersConfigService } from '~/services/file-viewers-config-service/file-viewers-config-service';
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
@@ -64,6 +65,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const tagService = new TagService(linkService);
     const searchService = new SearchService();
     const vocabularyService = new VocabularyService(config.vocabularyUrl);
+    const fileViewersConfig = new FileViewersConfigService(config.fileViewersConfigUrl);
 
     return {
         ancestorsService,
@@ -76,6 +78,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
         containerRequestService,
         containerService,
         favoriteService,
+        fileViewersConfig,
         groupsService,
         keepService,
         linkService,
diff --git a/src/store/file-viewers/file-viewers-actions.ts b/src/store/file-viewers/file-viewers-actions.ts
new file mode 100644 (file)
index 0000000..44bfd2f
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { ServiceRepository } from '~/services/services';
+import { propertiesActions } from '~/store/properties/properties-actions';
+import { FILE_VIEWERS_PROPERTY_NAME, DEFAULT_FILE_VIEWERS } from '~/store/file-viewers/file-viewers-selectors';
+import { FileViewerList } from '~/models/file-viewers-config';
+
+export const loadFileViewersConfig = async (dispatch: Dispatch, _: {}, { fileViewersConfig }: ServiceRepository) => {
+    
+    let config: FileViewerList;
+    try{
+        config = await fileViewersConfig.get();
+    } catch (e){
+        config = DEFAULT_FILE_VIEWERS;
+    }
+
+    dispatch(propertiesActions.SET_PROPERTY({
+        key: FILE_VIEWERS_PROPERTY_NAME,
+        value: config,
+    }));
+
+};
diff --git a/src/store/file-viewers/file-viewers-selectors.ts b/src/store/file-viewers/file-viewers-selectors.ts
new file mode 100644 (file)
index 0000000..abc9d3d
--- /dev/null
@@ -0,0 +1,12 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PropertiesState, getProperty } from '~/store/properties/properties';
+import { FileViewerList } from '~/models/file-viewers-config';
+
+export const FILE_VIEWERS_PROPERTY_NAME = 'fileViewers';
+
+export const DEFAULT_FILE_VIEWERS: FileViewerList = [];
+export const getFileViewers = (state: PropertiesState) =>
+    getProperty<FileViewerList>(FILE_VIEWERS_PROPERTY_NAME)(state) || DEFAULT_FILE_VIEWERS;
index b55648917329723d6cec917b8e73b53725695673..94f702e822913dda057fa6869925222bd3b7e5ae 100644 (file)
@@ -6,6 +6,7 @@ import { ContextMenuActionSet } from "../context-menu-action-set";
 import { RenameIcon, RemoveIcon } from "~/components/icon/icon";
 import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
 import { openFileRemoveDialog, openRenameFileDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
+import { FileViewerActions } from '~/views-components/context-menu/actions/file-viewer-actions';
 
 
 export const collectionFilesItemActionSet: ContextMenuActionSet = [[{
@@ -23,4 +24,7 @@ export const collectionFilesItemActionSet: ContextMenuActionSet = [[{
     execute: (dispatch, resource) => {
         dispatch<any>(openFileRemoveDialog(resource.uuid));
     }
+}], [{
+    component: FileViewerActions,
+    execute: () => { return; },
 }]];
diff --git a/src/views-components/context-menu/actions/file-viewer-actions.tsx b/src/views-components/context-menu/actions/file-viewer-actions.tsx
new file mode 100644 (file)
index 0000000..961e182
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { ListItemText, ListItem, ListItemIcon, Icon } from "@material-ui/core";
+import { RootState } from '~/store/store';
+import { getNodeValue } from '~/models/tree';
+import { CollectionDirectory, CollectionFile, CollectionFileType } from '~/models/collection-file';
+import { FileViewerList, FileViewer } from '~/models/file-viewers-config';
+import { getFileViewers } from '~/store/file-viewers/file-viewers-selectors';
+import { connect } from 'react-redux';
+import { OpenIcon } from '~/components/icon/icon';
+
+interface FileViewerActionProps {
+    fileUrl: string;
+    viewers: FileViewerList;
+}
+
+const mapStateToProps = (state: RootState): FileViewerActionProps => {
+    const { resource } = state.contextMenu;
+    if (resource) {
+        const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
+        if (file) {
+            const fileViewers = getFileViewers(state.properties);
+            return {
+                fileUrl: file.url,
+                viewers: fileViewers.filter(enabledViewers(file)),
+            };
+        }
+    }
+    return {
+        fileUrl: '',
+        viewers: [],
+    };
+};
+
+const enabledViewers = (file: CollectionFile | CollectionDirectory) =>
+    ({ extensions, collections }: FileViewer) => {
+        if (collections && file.type === CollectionFileType.DIRECTORY) {
+            return true;
+        } else if (extensions) {
+            return extensions.some(extension => file.name.endsWith(extension));
+        } else {
+            return true;
+        }
+    };
+
+const fillViewerUrl = (fileUrl: string, { url, filePathParam }: FileViewer) => {
+    const viewerUrl = new URL(url);
+    viewerUrl.searchParams.append(filePathParam, fileUrl);
+    return viewerUrl.href;
+};
+
+export const FileViewerActions = connect(mapStateToProps)(
+    ({ fileUrl, viewers, onClick }: FileViewerActionProps & { onClick: () => void }) =>
+        <>
+            {viewers.map(viewer =>
+                <ListItem
+                    button
+                    component='a'
+                    key={viewer.name}
+                    style={{ textDecoration: 'none' }}
+                    href={fillViewerUrl(fileUrl, viewer)}
+                    onClick={onClick}
+                    target='_blank'>
+                    <ListItemIcon>
+                        {
+                            viewer.iconUrl
+                                ? <Icon>
+                                    <img src={viewer.iconUrl} />
+                                </Icon>
+                                : <OpenIcon />
+                        }
+                    </ListItemIcon>
+                    <ListItemText>
+                        {viewer.name}
+                    </ListItemText>
+                </ListItem>
+            )}
+        </>);