tools/arvados_config.yml
cypress/fixtures/files/5mb.bin
cypress/fixtures/files/cat.png
+cypress/fixtures/files/banner.html
+cypress/fixtures/files/tooltips.txt
cypress/fixtures/webdav-propfind-outputs.xml
.yarn/releases/*
package.json
--- /dev/null
+<div>
+ <h1>Hi there</h1>
+ <h3>This is my amazing</h3>
+ <h5 style="color: red">Banner</h5>
+</div>
\ No newline at end of file
--- /dev/null
+{
+ "[data-cy=side-panel-tree]": "This allows you to navigate through the app"
+}
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Collection panel tests', function () {
+ let activeUser;
+ let adminUser;
+ let collectionUUID;
+
+ before(function () {
+ // Only set up common users once. These aren't set up as aliases because
+ // aliases are cleaned up after every test. Also it doesn't make sense
+ // to set the same users on beforeEach() over and over again, so we
+ // separate a little from Cypress' 'Best Practices' here.
+ cy.getUser('admin', 'Admin', 'User', true, true)
+ .as('adminUser').then(function () {
+ adminUser = this.adminUser;
+ }
+ );
+ cy.getUser('collectionuser1', 'Collection', 'User', false, true)
+ .as('activeUser').then(function () {
+ activeUser = this.activeUser;
+ });
+ cy.on('uncaught:exception', (err, runnable) => {console.error(err)});
+ });
+
+ beforeEach(function () {
+ cy.clearCookies();
+ cy.clearLocalStorage();
+ });
+
+ it('should re-show the banner', () => {
+ setupTheEnvironment();
+
+ cy.loginAs(adminUser);
+
+ cy.wait(2000);
+
+ cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+ cy.get('[title=Notifications]').click();
+ cy.get('li').contains('Restore Banner').click();
+
+ cy.wait(2000);
+
+ cy.get('[data-cy=confirmation-dialog-ok-btn]').should('be.visible');
+ });
+
+
+ it('should show tooltips and remove tooltips as localStorage key is present', () => {
+ setupTheEnvironment();
+
+ cy.loginAs(adminUser);
+
+ cy.wait(2000);
+
+ cy.get('[data-cy=side-panel-tree]').then(($el) => {
+ const el = $el.get(0) //native DOM element
+ expect(el._tippy).to.exist;
+ });
+
+ cy.wait(2000);
+
+ cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+ cy.get('[title=Notifications]').click();
+ cy.get('li').contains('Disable tooltips').click();
+
+ cy.get('[data-cy=side-panel-tree]').then(($el) => {
+ const el = $el.get(0) //native DOM element
+ expect(el._tippy).to.be.undefined;
+ });
+ });
+
+ const setupTheEnvironment = () => {
+ cy.createCollection(adminUser.token, {
+ name: `BannerTooltipTest${Math.floor(Math.random() * 999999)}`,
+ owner_uuid: adminUser.user.uuid,
+ }).as('bannerCollection');
+
+ cy.getAll('@bannerCollection')
+ .then(function ([bannerCollection]) {
+
+ collectionUUID=bannerCollection.uuid;
+
+ cy.loginAs(adminUser);
+
+ cy.goToPath(`/collections/${bannerCollection.uuid}`);
+
+ cy.get('[data-cy=upload-button]').click();
+
+ cy.fixture('files/banner.html').as('banner');
+ cy.fixture('files/tooltips.txt').as('tooltips');
+
+ cy.getAll('@banner', '@tooltips')
+ .then(([banner, tooltips]) => {
+ console.log(tooltips)
+ cy.get('[data-cy=drag-and-drop]').upload(banner, 'banner.html', false);
+ cy.get('[data-cy=drag-and-drop]').upload(tooltips, 'tooltips.json', false);
+ });
+
+ cy.get('[data-cy=form-submit-btn]').click();
+ cy.get('[data-cy=form-submit-btn]').should('not.exist');
+ cy.get('[data-cy=collection-files-right-panel]')
+ .contains('banner.html').should('exist');
+ cy.get('[data-cy=collection-files-right-panel]')
+ .contains('tooltips.json').should('exist');
+
+ cy.intercept({ method: 'GET', url: '**/arvados/v1/config?nocache=*' }, (req) => {
+ req.reply((res) => {
+ res.body.Workbench.BannerUUID = collectionUUID;
+ });
+ });
+ });
+ }
+});
{
prevSubject: 'element',
},
- (subject, file, fileName) => {
+ (subject, file, fileName, binaryMode = true) => {
cy.window().then(window => {
- const blob = b64toBlob(file, '', 512);
+ const blob = binaryMode
+ ? b64toBlob(file, '', 512)
+ : new Blob([file], {type: 'text/plain'});
const testFile = new window.File([blob], fileName);
cy.wrap(subject).trigger('drop', {
"set-value": "2.0.1",
"shell-escape": "^0.2.0",
"sinon": "7.3",
+ "tippy.js": "^6.3.7",
"tslint": "5.20.0",
"tslint-etc": "1.6.0",
"unionize": "2.1.2",
WebDAVDownload: { ExternalURL: '' },
WebShell: { ExternalURL: '' },
Workbench: {
- DisableSharingURLsUI: false,
- ArvadosDocsite: "",
- FileViewersConfigURL: "",
- WelcomePageHTML: "",
- InactivePageHTML: "",
- SSHHelpPageHTML: "",
- SSHHelpHostSuffix: "",
- SiteName: "",
- IdleTimeout: "0s"
- }
+ DisableSharingURLsUI: false,
+ ArvadosDocsite: "",
+ FileViewersConfigURL: "",
+ WelcomePageHTML: "",
+ InactivePageHTML: "",
+ SSHHelpPageHTML: "",
+ SSHHelpHostSuffix: "",
+ SiteName: "",
+ IdleTimeout: "0s"
+ },
},
Workbench: {
DisableSharingURLsUI: false,
import { snakeCase } from 'lodash';
import { CollectionResource, defaultCollectionSelectedFields } from 'models/collection';
import { AuthService } from '../auth-service/auth-service';
-import { CollectionService } from './collection-service';
+import { CollectionService, emptyCollectionPdh } from './collection-service';
describe('collection-service', () => {
let collectionService: CollectionService;
webdavClient = {
delete: jest.fn(),
upload: jest.fn(),
+ mkdir: jest.fn(),
} as any;
authService = {} as AuthService;
actions = {
progressFn: jest.fn(),
+ errorFn: jest.fn(),
} as any;
collectionService = new CollectionService(serverApi, webdavClient, authService, actions);
collectionService.update = jest.fn();
describe('deleteFiles', () => {
it('should remove no files', async () => {
// given
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
const filePaths: string[] = [];
- const collectionUUID = '';
+ const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
// when
await collectionService.deleteFiles(collectionUUID, filePaths);
// then
- expect(webdavClient.delete).not.toHaveBeenCalled();
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${collectionUUID}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {},
+ }
+ );
});
it('should remove only root files', async () => {
// given
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
const filePaths: string[] = ['/root/1', '/root/1/100', '/root/1/100/test.txt', '/root/2', '/root/2/200', '/root/3/300/test.txt'];
- const collectionUUID = '';
+ const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
// when
await collectionService.deleteFiles(collectionUUID, filePaths);
// then
- expect(webdavClient.delete).toHaveBeenCalledTimes(3);
- expect(webdavClient.delete).toHaveBeenCalledWith("c=/root/3/300/test.txt");
- expect(webdavClient.delete).toHaveBeenCalledWith("c=/root/2");
- expect(webdavClient.delete).toHaveBeenCalledWith("c=/root/1");
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${collectionUUID}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ '/root/3/300/test.txt': '',
+ '/root/2': '',
+ '/root/1': '',
+ },
+ }
+ );
});
- it('should remove files with uuid prefix', async () => {
+ it('should batch remove files', async () => {
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
// given
- const filePaths: string[] = ['/root/1'];
- const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
+ const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt'];
+ const collectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
// when
await collectionService.deleteFiles(collectionUUID, filePaths);
// then
- expect(webdavClient.delete).toHaveBeenCalledTimes(1);
- expect(webdavClient.delete).toHaveBeenCalledWith("c=zzzzz-tpzed-5o5tg0l9a57gxxx/root/1");
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${collectionUUID}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ '/root/1': '',
+ '/secondFile': '',
+ '/barefile.txt': '',
+ },
+ }
+ );
+ });
+ });
+
+ describe('renameFile', () => {
+ it('should rename file', async () => {
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+ const collectionUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+ const collectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+ const oldPath = '/old/path';
+ const newPath = '/new/filename';
+
+ await collectionService.renameFile(collectionUuid, collectionPdh, oldPath, newPath);
+
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${collectionUuid}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ [newPath]: `${collectionPdh}${oldPath}`,
+ [oldPath]: '',
+ },
+ }
+ );
});
});
+
+ describe('copyFiles', () => {
+ it('should batch copy files', async () => {
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+ const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt'];
+ const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+ const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+ const destinationPath = '/destinationPath';
+
+ // when
+ await collectionService.copyFiles(sourcePdh, filePaths, destinationUuid, destinationPath);
+
+ // then
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${destinationUuid}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ [`${destinationPath}/1`]: `${sourcePdh}/root/1`,
+ [`${destinationPath}/secondFile`]: `${sourcePdh}/secondFile`,
+ [`${destinationPath}/barefile.txt`]: `${sourcePdh}/barefile.txt`,
+ },
+ }
+ );
+ });
+
+ it('should copy files from rooth', async () => {
+ // Test copying from root paths
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+ const filePaths: string[] = ['/'];
+ const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+ const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+ const destinationPath = '/destinationPath';
+
+ await collectionService.copyFiles(sourcePdh, filePaths, destinationUuid, destinationPath);
+
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${destinationUuid}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ [`${destinationPath}`]: `${sourcePdh}/`,
+ },
+ }
+ );
+ });
+
+ it('should copy files to root path', async () => {
+ // Test copying to root paths
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+ const filePaths: string[] = ['/'];
+ const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+ const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+ const destinationPath = '/';
+
+ await collectionService.copyFiles(sourcePdh, filePaths, destinationUuid, destinationPath);
+
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${destinationUuid}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ "/": `${sourcePdh}/`,
+ },
+ }
+ );
+ });
+ });
+
+ describe('moveFiles', () => {
+ it('should batch move files', async () => {
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+ // given
+ const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt'];
+ const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+ const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+ const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+ const destinationPath = '/destinationPath';
+
+ // when
+ await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, destinationUuid, destinationPath);
+
+ // then
+ expect(serverApi.put).toHaveBeenCalledTimes(2);
+ // Verify copy
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${destinationUuid}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ [`${destinationPath}/rootFile`]: `${srcCollectionPdh}/rootFile`,
+ [`${destinationPath}/secondFile`]: `${srcCollectionPdh}/secondFile`,
+ [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`,
+ [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`,
+ },
+ }
+ );
+ // Verify delete
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${srcCollectionUUID}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ "/rootFile": "",
+ "/secondFile": "",
+ "/subpath/subfile": "",
+ "/barefile.txt": "",
+ },
+ }
+ );
+ });
+
+ it('should batch move files within collection', async () => {
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+ // given
+ const filePaths: string[] = ['/one', '/two', '/subpath/subfile', 'barefile.txt'];
+ const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+ const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+ const destinationPath = '/destinationPath';
+
+ // when
+ await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, srcCollectionUUID, destinationPath);
+
+ // then
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ // Verify copy
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${srcCollectionUUID}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ [`${destinationPath}/one`]: `${srcCollectionPdh}/one`,
+ ['/one']: '',
+ [`${destinationPath}/two`]: `${srcCollectionPdh}/two`,
+ ['/two']: '',
+ [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`,
+ ['/subpath/subfile']: '',
+ [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`,
+ ['/barefile.txt']: '',
+ },
+ }
+ );
+ });
+
+ it('should abort batch move when copy fails', async () => {
+ // Simulate failure to copy
+ serverApi.put = jest.fn(() => Promise.reject({
+ data: {},
+ response: {
+ "errors": ["error getting snapshot of \"rootFile\" from \"8cd9ce1dfa21c635b620b1bfee7aaa08+180\": file does not exist"]
+ }
+ }));
+ // given
+ const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt'];
+ const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+ const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+ const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+ const destinationPath = '/destinationPath';
+
+ // when
+ try {
+ await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, destinationUuid, destinationPath);
+ } catch {}
+
+ // then
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ // Verify copy
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${destinationUuid}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ [`${destinationPath}/rootFile`]: `${srcCollectionPdh}/rootFile`,
+ [`${destinationPath}/secondFile`]: `${srcCollectionPdh}/secondFile`,
+ [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`,
+ [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`,
+ },
+ }
+ );
+ });
+ });
+
+ describe('createDirectory', () => {
+ it('creates empty directory', async () => {
+ // given
+ const directoryNames = [
+ {in: 'newDir', out: 'newDir'},
+ {in: '/fooDir', out: 'fooDir'},
+ {in: '/anotherPath/', out: 'anotherPath'},
+ {in: 'trailingSlash/', out: 'trailingSlash'},
+ ];
+ const collectionUuid = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
+
+ for (var i = 0; i < directoryNames.length; i++) {
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+ // when
+ await collectionService.createDirectory(collectionUuid, directoryNames[i].in);
+ // then
+ expect(serverApi.put).toHaveBeenCalledTimes(1);
+ expect(serverApi.put).toHaveBeenCalledWith(
+ `/collections/${collectionUuid}`, {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: {
+ ["/" + directoryNames[i].out]: emptyCollectionPdh,
+ },
+ }
+ );
+ }
+ });
+ });
+
});
import { extractFilesData } from "./collection-service-files-response";
import { TrashableResourceService } from "services/common-service/trashable-resource-service";
import { ApiActions } from "services/api/api-actions";
-import { customEncodeURI } from "common/url";
import { Session } from "models/session";
+import { CommonService } from "services/common-service/common-service";
export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
+export const emptyCollectionPdh = 'd41d8cd98f00b204e9800998ecf8427e+0';
+
export class CollectionService extends TrashableResourceService<CollectionResource> {
constructor(serverApi: AxiosInstance, private webdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
super(serverApi, "collections", actions, [
return Promise.reject();
}
- async deleteFiles(collectionUuid: string, filePaths: string[]) {
- const sortedUniquePaths = Array.from(new Set(filePaths))
- .sort((a, b) => a.length - b.length)
- .reduce((acc, currentPath) => {
- const parentPathFound = acc.find((parentPath) => currentPath.indexOf(`${parentPath}/`) > -1);
-
- if (!parentPathFound) {
- return [...acc, currentPath];
- }
-
- return acc;
- }, []);
-
- for (const path of sortedUniquePaths) {
- if (path.indexOf(collectionUuid) === -1) {
- await this.webdavClient.delete(`c=${collectionUuid}${path}`);
+ private combineFilePath(parts: string[]) {
+ return parts.reduce((path, part) => {
+ // Trim leading and trailing slashes
+ const trimmedPart = part.split('/').filter(Boolean).join('/');
+ if (trimmedPart.length) {
+ const separator = path.endsWith('/') ? '' : '/';
+ return `${path}${separator}${trimmedPart}`;
} else {
- await this.webdavClient.delete(`c=${path}`);
+ return path;
}
- }
- await this.update(collectionUuid, { preserveVersion: true });
+ }, "/");
+ }
+
+ private replaceFiles(collectionUuid: string, fileMap: {}, showErrors?: boolean) {
+ const payload = {
+ collection: {
+ preserve_version: true
+ },
+ replace_files: fileMap
+ };
+
+ return CommonService.defaultResponse(
+ this.serverApi
+ .put<CollectionResource>(`/${this.resourceType}/${collectionUuid}`, payload),
+ this.actions,
+ true, // mapKeys
+ showErrors
+ );
}
async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = '') {
await this.update(collectionUuid, { preserveVersion: true });
}
- async moveFile(collectionUuid: string, oldPath: string, newPath: string) {
- await this.webdavClient.move(
- `c=${collectionUuid}${oldPath}`,
- `c=${collectionUuid}/${customEncodeURI(newPath)}`
- );
- await this.update(collectionUuid, { preserveVersion: true });
+ async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
+ return this.replaceFiles(collectionUuid, {
+ [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
+ [this.combineFilePath([oldPath])]: '',
+ });
}
extendFileURL = (file: CollectionDirectory | CollectionFile) => {
};
return this.webdavClient.upload(fileURL, [file], requestConfig);
}
+
+ deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) {
+ const optimizedFiles = files
+ .sort((a, b) => a.length - b.length)
+ .reduce((acc, currentPath) => {
+ const parentPathFound = acc.find((parentPath) => currentPath.indexOf(`${parentPath}/`) > -1);
+
+ if (!parentPathFound) {
+ return [...acc, currentPath];
+ }
+
+ return acc;
+ }, []);
+
+ const fileMap = optimizedFiles.reduce((obj, filePath) => {
+ return {
+ ...obj,
+ [this.combineFilePath([filePath])]: ''
+ }
+ }, {})
+
+ return this.replaceFiles(collectionUuid, fileMap, showErrors);
+ }
+
+ copyFiles(sourcePdh: string, files: string[], destinationCollectionUuid: string, destinationPath: string, showErrors?: boolean) {
+ const fileMap = files.reduce((obj, sourceFile) => {
+ const sourceFileName = sourceFile.split('/').filter(Boolean).slice(-1).join("");
+ return {
+ ...obj,
+ [this.combineFilePath([destinationPath, sourceFileName])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`
+ };
+ }, {});
+
+ return this.replaceFiles(destinationCollectionUuid, fileMap, showErrors);
+ }
+
+ moveFiles(sourceUuid: string, sourcePdh: string, files: string[], destinationCollectionUuid: string, destinationPath: string, showErrors?: boolean) {
+ if (sourceUuid === destinationCollectionUuid) {
+ const fileMap = files.reduce((obj, sourceFile) => {
+ const sourceFileName = sourceFile.split('/').filter(Boolean).slice(-1).join("");
+ return {
+ ...obj,
+ [this.combineFilePath([destinationPath, sourceFileName])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
+ [this.combineFilePath([sourceFile])]: '',
+ };
+ }, {});
+
+ return this.replaceFiles(sourceUuid, fileMap, showErrors)
+ } else {
+ return this.copyFiles(sourcePdh, files, destinationCollectionUuid, destinationPath, showErrors)
+ .then(() => {
+ return this.deleteFiles(sourceUuid, files, showErrors);
+ });
+ }
+ }
+
+ createDirectory(collectionUuid: string, path: string, showErrors?: boolean) {
+ const fileMap = {[this.combineFilePath([path])]: emptyCollectionPdh};
+
+ return this.replaceFiles(collectionUuid, fileMap, showErrors);
+ }
+
}
export const FILE_REMOVE_DIALOG = 'fileRemoveDialog';
-export const openFileRemoveDialog = (filePath: string) =>
+export const openFileRemoveDialog = (fileUuid: string) =>
(dispatch: Dispatch, getState: () => RootState) => {
- const file = getNodeValue(filePath)(getState().collectionPanelFiles);
+ const file = getNodeValue(fileUuid)(getState().collectionPanelFiles);
if (file) {
+ const filePath = getFileFullPath(file);
const isDirectory = file.type === CollectionFileType.DIRECTORY;
const title = isDirectory
? 'Removing directory'
dispatch(startSubmit(RENAME_FILE_DIALOG));
const oldPath = getFileFullPath(file);
const newPath = newFullPath;
- services.collectionService.moveFile(currentCollection.uuid, oldPath, newPath).then(() => {
+ services.collectionService.renameFile(currentCollection.uuid, currentCollection.portableDataHash, oldPath, newPath).then(() => {
dispatch(dialogActions.CLOSE_DIALOG({ id: RENAME_FILE_DIALOG }));
dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'File name changed.', hideDuration: 2000 }));
}).catch(e => {
import { Config } from 'common/config';
import { pluginConfig } from 'plugins';
import { MiddlewareListReducer } from 'common/plugintypes';
+import { tooltipsMiddleware } from './tooltips/tooltips-middleware';
import { sidePanelReducer } from './side-panel/side-panel-reducer'
import { bannerReducer } from './banner/banner-reducer';
routerMiddleware(history),
thunkMiddleware.withExtraArgument(services),
authMiddleware(services),
+ tooltipsMiddleware(services),
projectPanelMiddleware,
favoritePanelMiddleware,
allProcessessPanelMiddleware,
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionDirectory, CollectionFile } from "models/collection-file";
+import { Middleware, Store } from "redux";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
+import tippy, { createSingleton } from 'tippy.js';
+import 'tippy.js/dist/tippy.css';
+
+let running = false;
+let tooltipsContents = null;
+let tooltipsFetchFailed = false;
+export const TOOLTIP_LOCAL_STORAGE_KEY = "TOOLTIP_LOCAL_STORAGE_KEY";
+
+const tippySingleton = createSingleton([], {delay: 10});
+
+export const tooltipsMiddleware = (services: ServiceRepository): Middleware => (store: Store) => next => action => {
+ const state: RootState = store.getState();
+
+ if (state && state.auth && state.auth.config && state.auth.config.clusterConfig && state.auth.config.clusterConfig.Workbench) {
+ const hideTooltip = localStorage.getItem(TOOLTIP_LOCAL_STORAGE_KEY);
+ const { BannerUUID: bannerUUID } = state.auth.config.clusterConfig.Workbench;
+
+ if (bannerUUID && !tooltipsContents && !hideTooltip && !tooltipsFetchFailed && !running) {
+ running = true;
+ fetchTooltips(services, bannerUUID);
+ } else if (tooltipsContents && !hideTooltip && !tooltipsFetchFailed) {
+ applyTooltips();
+ }
+ }
+
+ return next(action);
+};
+
+const fetchTooltips = (services, bannerUUID) => {
+ services.collectionService.files(bannerUUID)
+ .then(results => {
+ const tooltipsFile: CollectionDirectory | CollectionFile | undefined = results.find(({ name }) => name === 'tooltips.json');
+
+ if (tooltipsFile) {
+ running = true;
+ services.collectionService.getFileContents(tooltipsFile as CollectionFile)
+ .then(data => {
+ tooltipsContents = JSON.parse(data);
+ applyTooltips();
+ })
+ .catch(() => {})
+ .finally(() => {
+ running = false;
+ });
+ } else {
+ tooltipsFetchFailed = true;
+ }
+ })
+ .catch(() => {})
+ .finally(() => {
+ running = false;
+ });
+};
+
+const applyTooltips = () => {
+ const tippyInstances: any[] = Object.keys(tooltipsContents as any)
+ .map((key) => {
+ const content = (tooltipsContents as any)[key]
+ const element = document.querySelector(key);
+
+ if (element) {
+ const hasTippyAttatched = !!(element as any)._tippy;
+
+ if (!hasTippyAttatched && tooltipsContents) {
+ return tippy(element as any, { content });
+ }
+ }
+
+ return null;
+ })
+ .filter(data => !!data);
+
+ if (tippyInstances.length > 0) {
+ tippySingleton.setInstances(tippyInstances);
+ }
+};
\ No newline at end of file
);
export const SearchBarProjectField = () =>
- <ProjectInput input={{
+ <ProjectInput required={false} input={{
id: "projectObject",
label: "Limit search to Project"
} as ProjectCommandInputParameter}
import bannerActions from "store/banner/banner-action";
import { BANNER_LOCAL_STORAGE_KEY } from "views-components/baner/banner";
import { RootState } from "store/store";
+import { TOOLTIP_LOCAL_STORAGE_KEY } from "store/tooltips/tooltips-middleware";
+import { useCallback } from "react";
const mapStateToProps = (state: RootState): NotificationsMenuProps => ({
isOpen: state.banner.isOpen,
export const NotificationsMenuComponent = (props: NotificationsMenuComponentProps) => {
const { isOpen, openBanner } = props;
- const result = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY);
+ const bannerResult = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY);
+ const tooltipResult = localStorage.getItem(TOOLTIP_LOCAL_STORAGE_KEY);
const menuItems: any[] = [];
- if (!isOpen && result) {
+ if (!isOpen && bannerResult) {
menuItems.push(<MenuItem><span onClick={openBanner}>Restore Banner</span></MenuItem>);
}
+ const toggleTooltips = useCallback(() => {
+ if (tooltipResult) {
+ localStorage.removeItem(TOOLTIP_LOCAL_STORAGE_KEY);
+ } else {
+ localStorage.setItem(TOOLTIP_LOCAL_STORAGE_KEY, 'true');
+ }
+ window.location.reload();
+ }, [tooltipResult]);
+
+ if (tooltipResult) {
+ menuItems.push(<MenuItem><span onClick={toggleTooltips}>Enable tooltips</span></MenuItem>);
+ } else {
+ menuItems.push(<MenuItem><span onClick={toggleTooltips}>Disable tooltips</span></MenuItem>);
+ }
+
if (menuItems.length === 0) {
menuItems.push(<MenuItem>You are up to date</MenuItem>);
}
id="account-menu"
title="Notifications">
{
- menuItems.map(item => item)
+ menuItems.map((item, i) => <div key={i}>{item}</div>)
}
</DropdownMenu>);
}
type GenericInputContainerProps = GenericInputProps & {
component: React.ComponentType<GenericInputProps>;
+ required?: boolean;
};
export const GenericInput = ({ component: Component, ...props }: GenericInputContainerProps) => {
return <FormGroup>
<FormLabel
focused={props.meta.active}
- required={isRequiredInput(props.commandInput)}
+ required={props.required !== undefined ? props.required : isRequiredInput(props.commandInput)}
error={props.meta.touched && !!props.meta.error}>
{getInputLabel(props.commandInput)}
</FormLabel>
}
</FormHelperText>
</FormGroup>;
-};
\ No newline at end of file
+};
const require: any = (value?: ProjectResource) => (value === undefined);
export interface ProjectInputProps {
- required?: boolean;
+ required: boolean;
input: ProjectCommandInputParameter;
options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
}
format={format}
validate={required ? require : undefined}
{...{
- options
+ options,
+ required
}} />;
const format = (value?: ProjectResource) => value ? value.name : '';
export const ProjectInputComponent = connect(mapStateToProps)(
class ProjectInputComponent extends React.Component<GenericInputProps & DispatchProp & HasUserUuid & {
options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+ required?: boolean;
}, ProjectInputComponentState> {
state: ProjectInputComponentState = {
open: false,
languageName: node
linkType: hard
+"@popperjs/core@npm:^2.9.0":
+ version: 2.11.6
+ resolution: "@popperjs/core@npm:2.11.6"
+ checksum: 47fb328cec1924559d759b48235c78574f2d71a8a6c4c03edb6de5d7074078371633b91e39bbf3f901b32aa8af9b9d8f82834856d2f5737a23475036b16817f0
+ languageName: node
+ linkType: hard
+
"@samverschueren/stream-to-observable@npm:^0.3.0":
version: 0.3.1
resolution: "@samverschueren/stream-to-observable@npm:0.3.1"
set-value: 2.0.1
shell-escape: ^0.2.0
sinon: 7.3
+ tippy.js: ^6.3.7
ts-mock-imports: 1.3.7
tslint: 5.20.0
tslint-etc: 1.6.0
languageName: node
linkType: hard
+"tippy.js@npm:^6.3.7":
+ version: 6.3.7
+ resolution: "tippy.js@npm:6.3.7"
+ dependencies:
+ "@popperjs/core": ^2.9.0
+ checksum: cac955318a65288e8d2dca05059878b003c6e66f92c94f7810f5bc5448eb6646abdf7dacc9bd00020e2611592598d0aae3a28ec9a45349a159603c3fdddce5fb
+ languageName: node
+ linkType: hard
+
"tmp@npm:^0.0.33":
version: 0.0.33
resolution: "tmp@npm:0.0.33"