activeUser = this.activeUser;
}
);
- })
+ });
beforeEach(function() {
- cy.clearCookies()
- cy.clearLocalStorage()
- })
+ cy.clearCookies();
+ cy.clearLocalStorage();
+ });
it('shows collection by URL', function() {
cy.loginAs(activeUser);
})
})
+ it('renames a file using valid names', function() {
+ // Creates the collection using the admin token so we can set up
+ // a bogus manifest text without block signatures.
+ cy.createCollection(adminUser.token, {
+ name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+ owner_uuid: activeUser.user.uuid,
+ manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"})
+ .as('testCollection').then(function() {
+ cy.loginAs(activeUser);
+ cy.visit(`/collections/${this.testCollection.uuid}`);
+ const nameTransitions = [
+ ['bar', '&'],
+ ['&', 'foo'],
+ ['foo', '&'],
+ ['&', 'I ❤️ ⛵️'],
+ ['I ❤️ ⛵️', '...']
+ ];
+ nameTransitions.forEach(([from, to]) => {
+ cy.get('[data-cy=collection-files-panel]')
+ .contains(`${from}`).rightclick();
+ cy.get('[data-cy=context-menu]')
+ .contains('Rename')
+ .click();
+ cy.get('[data-cy=form-dialog]')
+ .should('contain', 'Rename')
+ .within(() => {
+ cy.get('input').type(`{selectall}{backspace}${to}`);
+ });
+ cy.get('[data-cy=form-submit-btn]').click();
+ cy.get('[data-cy=collection-files-panel]')
+ .should('not.contain', `${from}`)
+ .and('contain', `${to}`);
+ })
+ });
+ });
+
+ it('renames a file to a different directory', function() {
+ // Creates the collection using the admin token so we can set up
+ // a bogus manifest text without block signatures.
+ cy.createCollection(adminUser.token, {
+ name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+ owner_uuid: activeUser.user.uuid,
+ manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"})
+ .as('testCollection').then(function() {
+ cy.loginAs(activeUser);
+ cy.visit(`/collections/${this.testCollection.uuid}`);
+ // Rename 'bar' to 'subdir/foo'
+ cy.get('[data-cy=collection-files-panel]')
+ .contains('bar').rightclick();
+ cy.get('[data-cy=context-menu]')
+ .contains('Rename')
+ .click();
+ cy.get('[data-cy=form-dialog]')
+ .should('contain', 'Rename')
+ .within(() => {
+ cy.get('input').type(`{selectall}{backspace}subdir/foo`);
+ });
+ cy.get('[data-cy=form-submit-btn]').click();
+ cy.get('[data-cy=collection-files-panel]')
+ .should('not.contain', 'bar')
+ .and('contain', 'subdir');
+ // Look for the "arrow icon" and expand the "subdir" directory.
+ cy.get('[data-cy=virtual-file-tree] > div > i').click();
+ // Rename 'subdir/foo' to 'baz'
+ cy.get('[data-cy=collection-files-panel]')
+ .contains('foo').rightclick();
+ cy.get('[data-cy=context-menu]')
+ .contains('Rename')
+ .click();
+ cy.get('[data-cy=form-dialog]')
+ .should('contain', 'Rename')
+ .within(() => {
+ cy.get('input')
+ .should('have.value', 'subdir/foo')
+ .type(`{selectall}{backspace}baz`);
+ });
+ cy.get('[data-cy=form-submit-btn]').click();
+ cy.get('[data-cy=collection-files-panel]')
+ .should('contain', 'subdir') // empty dir kept
+ .and('contain', 'baz');
+ });
+ });
+
+ it('tries to rename a file with an illegal names', function() {
+ // Creates the collection using the admin token so we can set up
+ // a bogus manifest text without block signatures.
+ cy.createCollection(adminUser.token, {
+ name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+ owner_uuid: activeUser.user.uuid,
+ manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"})
+ .as('testCollection').then(function() {
+ cy.loginAs(activeUser);
+ cy.visit(`/collections/${this.testCollection.uuid}`);
+ const illegalNamesFromUI = [
+ ['.', "Name cannot be '.' or '..'"],
+ ['..', "Name cannot be '.' or '..'"],
+ ['', 'This field is required'],
+ [' ', 'Leading/trailing whitespaces not allowed'],
+ [' foo', 'Leading/trailing whitespaces not allowed'],
+ ['foo ', 'Leading/trailing whitespaces not allowed'],
+ ['//foo', 'Empty dir name not allowed']
+ ]
+ illegalNamesFromUI.forEach(([name, errMsg]) => {
+ cy.get('[data-cy=collection-files-panel]')
+ .contains('bar').rightclick();
+ cy.get('[data-cy=context-menu]')
+ .contains('Rename')
+ .click();
+ cy.get('[data-cy=form-dialog]')
+ .should('contain', 'Rename')
+ .within(() => {
+ cy.get('input').type(`{selectall}{backspace}${name}`);
+ });
+ cy.get('[data-cy=form-dialog]')
+ .should('contain', 'Rename')
+ .within(() => {
+ cy.contains(`${errMsg}`);
+ });
+ cy.get('[data-cy=form-cancel-btn]').click();
+ })
+ });
+ });
+
it('can correctly display old versions', function() {
- const colName = `Versioned Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+ const colName = `Versioned Collection ${Math.floor(Math.random() * 999999)}`;
let colUuid = '';
let oldVersionUuid = '';
// Make sure no other collections with this name exist
inactiveUser = this.inactiveUser;
}
);
- randomUser.username = `randomuser${Math.floor(Math.random() * Math.floor(999999))}`;
+ randomUser.username = `randomuser${Math.floor(Math.random() * 999999)}`;
randomUser.password = {
crypt: 'zpAReoZzPnwmQ',
clear: 'topsecret',
cy.doRequest('PUT', `/arvados/v1/api_client_authorizations/${tokenUuid}`, {
id: tokenUuid,
api_client_authorization: JSON.stringify({
- api_token: `randomToken${Math.floor(Math.random() * Math.floor(999999))}`
+ api_token: `randomToken${Math.floor(Math.random() * 999999)}`
})
}, null, activeUser.token, true);
// Should log the user out.
r.open(config.method,
`${this.defaults.baseURL
? this.defaults.baseURL+'/'
- : ''}${config.url}`);
+ : ''}${encodeURI(config.url)}`);
const headers = { ...this.defaults.headers, ...config.headers };
Object
.keys(headers)
r.upload.addEventListener('progress', config.onUploadProgress);
}
+ // This event gets triggered on *any* server response
r.addEventListener('load', () => {
- if (r.status === 404) {
+ if (r.status >= 400) {
return reject(r);
} else {
return resolve(r);
}
});
+ // This event gets triggered on network errors
r.addEventListener('error', () => {
return reject(r);
});
export const getTagValue = (document: Document | Element, tagName: string, defaultValue: string) => {
const [el] = Array.from(document.getElementsByTagName(tagName));
- return decodeURI(el ? el.innerHTML : defaultValue);
+ return decodeURI(el ? htmlDecode(el.innerHTML) : defaultValue);
+};
+
+const htmlDecode = (input: string) => {
+ const out = input.split(' ').map((i) => {
+ const doc = new DOMParser().parseFromString(i, "text/html");
+ if (doc.documentElement !== null) {
+ return doc.documentElement.textContent || '';
+ }
+ return '';
+ });
+ return out.join(' ');
};
: <div style={{ height: 'calc(100% - 60px)' }}>
<FileTree
onMenuOpen={(ev, item) => onItemMenuOpen(ev, item, isWritable)}
- {...treeProps}
- items={treeProps.items} /></div>}
+ {...treeProps} /></div>}
</>
}
</Card>);
</DialogContent>
<DialogActions className={props.classes.dialogActions}>
<Button
+ data-cy='form-cancel-btn'
onClick={props.closeDialog}
className={props.classes.button}
color="primary"
+++ /dev/null
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from "react";
-import { InjectedFormProps, Field } from "redux-form";
-import { Dialog, DialogTitle, DialogContent, DialogActions, Button, DialogContentText, CircularProgress } from "@material-ui/core";
-import { WithDialogProps } from "~/store/dialog/with-dialog";
-import { TextField } from "../text-field/text-field";
-
-export const RenameDialog = (props: WithDialogProps<string> & InjectedFormProps<{ name: string }>) =>
- <form>
- <Dialog open={props.open}>
- <DialogTitle>{`Rename`}</DialogTitle>
- <DialogContent>
- <DialogContentText>
- {`Please, enter a new name for ${props.data}`}
- </DialogContentText>
- <Field
- name='name'
- component={TextField}
- />
- </DialogContent>
- <DialogActions>
- <Button
- variant='text'
- color='primary'
- disabled={props.submitting}
- onClick={props.closeDialog}>
- Cancel
- </Button>
- <Button
- variant='contained'
- color='primary'
- type='submit'
- onClick={props.handleSubmit}
- disabled={props.pristine || props.invalid || props.submitting}>
- {props.submitting
- ? <CircularProgress size={20} />
- : 'Ok'}
- </Button>
- </DialogActions>
- </Dialog>
- </form>;
: undefined;
};
- return <div style={style}>
+ return <div data-cy='virtual-file-tree' style={style}>
<ListItem button className={listItem}
style={{
paddingLeft: (level + 1) * levelIndentation,
export interface RenameFileDialogData {
name: string;
id: string;
+ path: string;
}
export const openRenameFileDialog = (data: RenameFileDialogData) =>
dispatch(dialogActions.OPEN_DIALOG({ id: RENAME_FILE_DIALOG, data }));
};
-export const renameFile = (newName: string) =>
+export const renameFile = (newFullPath: string) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const dialog = getDialog<RenameFileDialogData>(getState().dialog, RENAME_FILE_DIALOG);
const currentCollection = getState().collectionPanel.item;
if (file) {
dispatch(startSubmit(RENAME_FILE_DIALOG));
const oldPath = getFileFullPath(file);
- const newPath = getFileFullPath({ ...file, name: newName });
+ const newPath = newFullPath;
try {
await services.collectionService.moveFile(currentCollection.uuid, oldPath, newPath);
dispatch<any>(loadCollectionFiles(currentCollection.uuid));
dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'File name changed.', hideDuration: 2000 }));
} catch (e) {
const errors: FormErrors<RenameFileDialogData, string> = {
- name: 'Could not rename the file'
+ path: `Could not rename the file: ${e.responseText}`
};
dispatch(stopSubmit(RENAME_FILE_DIALOG, errors));
}
export const disallowDotName = /^\.{1,2}$/;
export const disallowSlash = /\//;
-
-const ERROR_MESSAGE = "Name cannot be '.' or '..' or contain '/' characters";
+export const disallowLeadingWhitespaces = /^\s+/;
+export const disallowTrailingWhitespaces = /\s+$/;
export const validName = (value: string) => {
return [disallowDotName, disallowSlash].find(aRule => value.match(aRule) !== null)
- ? ERROR_MESSAGE
+ ? "Name cannot be '.' or '..' or contain '/' characters"
: undefined;
};
? "Name cannot be '.' or '..'"
: undefined;
};
+
+export const validFileName = (value: string) => {
+ return [
+ disallowLeadingWhitespaces,
+ disallowTrailingWhitespaces
+ ].find(aRule => value.match(aRule) !== null)
+ ? `Leading/trailing whitespaces not allowed on '${value}'`
+ : undefined;
+};
+
+export const validFilePath = (filePath: string) => {
+ const errors = filePath.split('/').map(pathPart => {
+ if (pathPart === "") { return "Empty dir name not allowed"; }
+ return validNameAllowSlash(pathPart) || validFileName(pathPart);
+ });
+ return errors.filter(e => e !== undefined)[0];
+};
\ No newline at end of file
import { maxLength } from './max-length';
import { isRsaKey } from './is-rsa-key';
import { isRemoteHost } from "./is-remote-host";
-import { validName, validNameAllowSlash } from "./valid-name";
+import { validFilePath, validName, validNameAllowSlash } from "./valid-name";
export const TAG_KEY_VALIDATION = [require, maxLength(255)];
export const TAG_VALUE_VALIDATION = [require, maxLength(255)];
export const COPY_NAME_VALIDATION = [require, maxLength(255)];
export const COPY_FILE_VALIDATION = [require];
+export const RENAME_FILE_VALIDATION = [require, validFilePath];
export const MOVE_TO_VALIDATION = [require];
name: "Rename",
icon: RenameIcon,
execute: (dispatch, resource) => {
- dispatch<any>(openRenameFileDialog({ name: resource.name, id: resource.uuid }));
+ dispatch<any>(openRenameFileDialog({
+ name: resource.name,
+ id: resource.uuid,
+ path: resource.uuid.split('/').slice(1).join('/') }));
}
},
{
beforeEach(() => {
props = {
onClick: jest.fn(),
- href: 'https://collections.ardev.roche.com/c=ardev-4zz18-k0hamvtwyit6q56/t=1ha4ykd3w14ed19b2gh3uyjrjup38vsx27x1utwdne0bxcfg5d/LIMS/1.html',
+ href: 'https://collections.example.com/c=zzzzz-4zz18-k0hamvtwyit6q56/t=xxxxxxxx/LIMS/1.html',
};
});
beforeEach(() => {
props = {
onClick: jest.fn(),
- href: 'https://collections.ardev.roche.com/c=ardev-4zz18-k0hamvtwyit6q56/t=1ha4ykd3w14ed19b2gh3uyjrjup38vsx27x1utwdne0bxcfg5d/LIMS/1.html',
+ href: 'https://collections.example.com/c=zzzzz-4zz18-k0hamvtwyit6q56/t=xxxxxxx/LIMS/1.html',
};
});
withDialog(COLLECTION_COPY_FORM_NAME),
reduxForm<CopyFormDialogData>({
form: COLLECTION_COPY_FORM_NAME,
+ touchOnChange: true,
onSubmit: (data, dispatch) => {
dispatch(copyCollection(data));
}
withDialog(COLLECTION_CREATE_FORM_NAME),
reduxForm<CollectionCreateFormDialogData>({
form: COLLECTION_CREATE_FORM_NAME,
+ touchOnChange: true,
onSubmit: (data, dispatch) => {
// Somehow an extra field called 'files' gets added, copy
// the data object to get rid of it.
export const UpdateCollectionDialog = compose(
withDialog(COLLECTION_UPDATE_FORM_NAME),
reduxForm<CollectionUpdateFormDialogData>({
+ touchOnChange: true,
form: COLLECTION_UPDATE_FORM_NAME,
onSubmit: (data, dispatch) => {
dispatch(updateCollection(data));
import { TextField } from '~/components/text-field/text-field';
import { RENAME_FILE_DIALOG, RenameFileDialogData, renameFile } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
import { WarningCollection } from '~/components/warning-collection/warning-collection';
+import { RENAME_FILE_VALIDATION } from '~/validators/validators';
export const RenameFileDialog = compose(
withDialog(RENAME_FILE_DIALOG),
reduxForm({
form: RENAME_FILE_DIALOG,
- onSubmit: (data: { name: string }, dispatch) => {
- dispatch<any>(renameFile(data.name));
+ touchOnChange: true,
+ onSubmit: (data: { path: string }, dispatch) => {
+ dispatch<any>(renameFile(data.path));
}
})
-)((props: WithDialogProps<RenameFileDialogData> & InjectedFormProps<{ name: string }>) =>
+)((props: WithDialogProps<RenameFileDialogData> & InjectedFormProps<{ name: string, path: string }>) =>
<FormDialog
dialogTitle='Rename'
formFields={RenameDialogFormFields}
{`Please, enter a new name for ${props.data.name}`}
</DialogContentText>
<Field
- name='name'
+ name='path'
component={TextField}
autoFocus={true}
+ validate={RENAME_FILE_VALIDATION}
/>
- <WarningCollection text="Renaming a file will change content address." />
+ <WarningCollection text="Renaming a file will change the collection's content address." />
</>;