Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FileTree and WebDav updates to support module editor scenario #259

Merged
merged 16 commits into from
Jun 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@labkey/components",
"version": "0.64.3",
"version": "0.65.0",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"main": "dist/components.js",
"module": "dist/components.js",
Expand Down
11 changes: 11 additions & 0 deletions packages/components/releaseNotes/labkey/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages.

### version 0.65.0
*Released*: 2 June 2020
* FileTree component
- add prop to remove checkbox selection option from node display
- support font awesome icon display for file based on WebDav iconFontCls data
- call the onFileSelect callback function on node select for non-checkbox case
- make the arrow toggle smaller
* WebDav model updates for module editor browser scenario
- add contentType and options properties to model
- add param to getWebDavFiles for the non-@files case

## version 0.64.3
*Released*: 2 June 2020
* Item 7373: Move base user permission check helpers from Sample Manager to User model
Expand Down
25 changes: 22 additions & 3 deletions packages/components/src/components/files/FileTree.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import renderer from 'react-test-renderer';

import { FileTree } from './FileTree';
import { fetchFileTestTree } from './FileTreeTest';
Expand All @@ -9,7 +9,8 @@ const waitForLoad = jest.fn(component => Promise.resolve(!component.state().load

describe('FileTree', () => {
test('with data', () => {
const tree = shallow(<FileTree loadData={fetchFileTestTree} onFileSelect={jest.fn()} />);
const component = <FileTree loadData={fetchFileTestTree} onFileSelect={jest.fn()} />;
const tree = shallow(component);

return waitForLoad(tree).then(() => {
const node = tree.childAt(0).dive().childAt(0).dive().find('NodeHeader');
Expand All @@ -19,7 +20,25 @@ describe('FileTree', () => {

return waitForLoad(tree).then(() => {
expect(tree.state()['data']['children'].length).toEqual(4);
expect(toJson(tree)).toMatchSnapshot();
expect(renderer.create(tree.getElement()).toJSON()).toMatchSnapshot();
tree.unmount();
});
});
});

test('with data allowMultiSelect false', () => {
const component = <FileTree allowMultiSelect={false} loadData={fetchFileTestTree} onFileSelect={jest.fn()} />;
const tree = shallow(component);

return waitForLoad(tree).then(() => {
const node = tree.childAt(0).dive().childAt(0).dive().find('NodeHeader');
expect(node.prop('node')['children'].length).toEqual(0);

node.simulate('click');

return waitForLoad(tree).then(() => {
expect(tree.state()['data']['children'].length).toEqual(4);
expect(renderer.create(tree.getElement()).toJSON()).toMatchSnapshot();
tree.unmount();
});
});
Expand Down
105 changes: 74 additions & 31 deletions packages/components/src/components/files/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Treebeard, decorators } from 'react-treebeard';
import React, { PureComponent } from 'react';

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFolder, faFileAlt } from '@fortawesome/free-solid-svg-icons';
import { faFolder, faFileAlt, faFolderOpen } from '@fortawesome/free-solid-svg-icons';
import { Checkbox, Alert } from 'react-bootstrap';
import { List } from 'immutable';

Expand Down Expand Up @@ -46,11 +46,11 @@ const customStyle = {
position: 'absolute',
top: '50%',
left: '50%',
margin: '-7px 0 0 -7px',
height: '14px',
margin: '-10px 0 0 -5px',
height: '10px',
},
height: 14,
width: 14,
height: 10,
width: 10,
arrow: {
fill: '#777',
strokeWidth: 0,
Expand Down Expand Up @@ -103,9 +103,9 @@ const nodeIsEmpty = (id: string): boolean => {
};

const Header = props => {
const { style, onSelect, node, customStyles, checked, handleCheckbox } = props;
const iconType = node.children ? 'folder' : 'file-text';
const icon = iconType === 'folder' ? faFolder : faFileAlt;
const { style, onSelect, node, customStyles, checked, handleCheckbox, useFileIconCls } = props;
const isDirectory = node.children !== undefined;
const icon = isDirectory ? (node.toggled ? faFolderOpen : faFolder) : faFileAlt;

if (nodeIsEmpty(node.id)) {
return <div className="filetree-empty-directory">No Files Found</div>;
Expand All @@ -125,11 +125,28 @@ const Header = props => {
};

return (
<span className={'filetree-checkbox-container' + (iconType === 'folder' ? '' : ' filetree-leaf-node')}>
<Checkbox id={CHECK_ID_PREFIX + node.id} checked={checked} onChange={handleCheckbox} onClick={checkClick} />
<span
className={
'filetree-checkbox-container' +
(isDirectory ? '' : ' filetree-leaf-node') +
(node.active ? ' active' : '')
}
>
{handleCheckbox && (
<Checkbox
id={CHECK_ID_PREFIX + node.id}
checked={checked}
onChange={handleCheckbox}
onClick={checkClick}
/>
)}
<div style={style.base} onClick={onSelect}>
<div style={node.selected ? { ...style.title, ...customStyles.header.title } : style.title}>
<FontAwesomeIcon icon={icon} className="filetree-folder-icon" />
{!isDirectory && useFileIconCls && node.data && node.data.iconFontCls ? (
<i className={node.data.iconFontCls + ' filetree-folder-icon'} />
) : (
<FontAwesomeIcon icon={icon} className="filetree-folder-icon" />
)}
{node.name}
</div>
</div>
Expand All @@ -139,7 +156,9 @@ const Header = props => {

interface FileTreeProps {
loadData: (directory?: string) => Promise<any>;
onFileSelect: (name: string, path: string, checked: boolean, isDirectory: boolean) => void;
onFileSelect: (name: string, path: string, checked: boolean, isDirectory: boolean, node: any) => void;
allowMultiSelect?: boolean;
useFileIconCls?: boolean;
}

interface FileTreeState {
Expand All @@ -150,7 +169,14 @@ interface FileTreeState {
loading: boolean; // Only used for testing
}

// TODO add typings for nodes, see https://github.com/storybookjs/react-treebeard/issues/186#issuecomment-502162650

export class FileTree extends PureComponent<FileTreeProps, FileTreeState> {
static defaultProps = {
allowMultiSelect: true,
useFileIconCls: false,
};

constructor(props: FileTreeProps) {
super(props);

Expand Down Expand Up @@ -197,8 +223,21 @@ export class FileTree extends PureComponent<FileTreeProps, FileTreeState> {
}

headerDecorator = props => {
const { allowMultiSelect, useFileIconCls } = this.props;
const { checked } = this.state;
return <Header {...props} checked={checked.contains(props.node.id)} handleCheckbox={this.handleCheckbox} />;

if (allowMultiSelect) {
return (
<Header
{...props}
useFileIconCls={useFileIconCls}
checked={checked.contains(props.node.id)}
handleCheckbox={this.handleCheckbox}
/>
);
} else {
return <Header {...props} useFileIconCls={useFileIconCls} />;
}
};

getNodeIdFromId = (id: string): string => {
Expand Down Expand Up @@ -246,11 +285,11 @@ export class FileTree extends PureComponent<FileTreeProps, FileTreeState> {
};

// Callback to parent with actual path of selected file
onFileSelect = (id: string, checked: boolean, isDirectory: boolean): void => {
onFileSelect = (id: string, checked: boolean, isDirectory: boolean, node: any): void => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't notice this when I did the initial implementation, but looks like someone has already done a little work defining the types here in this issue. If you are up for providing some types. (Although looks like it's missing node.selected).

storybook-eol/react-treebeard#186 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not going to take that on as part of this PR, but I'll add a TODO comment to the component.

const { onFileSelect } = this.props;

if (!nodeIsEmpty(id)) {
onFileSelect(this.getNameFromId(id), this.getPathFromId(id), checked, isDirectory);
onFileSelect(this.getNameFromId(id), this.getPathFromId(id), checked, isDirectory, node);
}
};

Expand All @@ -267,7 +306,7 @@ export class FileTree extends PureComponent<FileTreeProps, FileTreeState> {
if (checked) {
this.setState(
state => ({ checked: state.checked.push(node.id) }),
() => this.onFileSelect(node.id, checked, !!node.children)
() => this.onFileSelect(node.id, checked, !!node.children, node)
);
} else {
this.setState(
Expand All @@ -276,7 +315,7 @@ export class FileTree extends PureComponent<FileTreeProps, FileTreeState> {
return check !== node.id;
}) as List<string>,
}),
() => this.onFileSelect(node.id, checked, !!node.children)
() => this.onFileSelect(node.id, checked, !!node.children, node)
);
}
}
Expand Down Expand Up @@ -350,27 +389,31 @@ export class FileTree extends PureComponent<FileTreeProps, FileTreeState> {
// we make a clone of this.state.data for setState. Directly manipulating anything in this.state is NOT a recommended React
// pattern. This is done in this case to work with the treebeard package, but should not be copied elsewhere.
onToggle = (node: any, toggled: boolean, callback?: () => any): void => {
const { allowMultiSelect } = this.props;
const { cursor, data } = this.state;

if (cursor) {
node.active = false;
cursor.active = false;
this.setState(() => ({ cursor, data: { ...data } }));
}
node.active = true;
node.toggled = toggled;

if (node.children) {
// load data if not already loaded
if (node.children.length === 0) {
node.children = [{ id: node.id + '|' + LOADING_FILE_NAME }];
this.setState(
() => ({ cursor: node, data: { ...data } }),
() => {
this.loadDirectory(node.id, callback);
}
);
} else {
this.setState(() => ({ cursor: node, data: { ...data }, error: undefined }), callback);
}
// load data if not already loaded
if (node.children && node.children.length === 0) {
node.children = [{ id: node.id + '|' + LOADING_FILE_NAME }];
this.setState(
() => ({ cursor: node, data: { ...data } }),
() => {
this.loadDirectory(node.id, callback);
}
);
} else {
this.setState(() => ({ cursor: node, data: { ...data }, error: undefined }), callback);
}

if (!allowMultiSelect) {
this.onFileSelect(node.id, true, !!node.children, node);
}
};

Expand Down
11 changes: 10 additions & 1 deletion packages/components/src/components/files/FileTreeTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ const data = {
children: [
{
name: 'parent1',
children: [{ name: 'child1' }, { name: 'child2' }],
children: [
{
name: 'child1.xlsx',
data: { iconFontCls: 'fa fa-file-excel-o' },
},
{
name: 'child2.pdf',
data: { iconFontCls: 'fa fa-file-pdf-o' },
},
],
},
{
name: 'loading parent',
Expand Down
13 changes: 8 additions & 5 deletions packages/components/src/components/files/WebDav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DEFAULT_FILE, IFile } from './models';

export class WebDavFile extends Record(DEFAULT_FILE) implements IFile {
contentLength: number;
contentType: string;
created: string;
createdBy: string;
createdById: number;
Expand All @@ -19,6 +20,7 @@ export class WebDavFile extends Record(DEFAULT_FILE) implements IFile {
isLeaf: boolean;
lastModified: string;
name: string;
options: string;
propertiesRowId?: number;

constructor(params?: IFile) {
Expand All @@ -36,17 +38,17 @@ export class WebDavFile extends Record(DEFAULT_FILE) implements IFile {
lastModified: values.lastmodified,
downloadUrl: values.href ? values.href + '?contentDisposition=attachment' : undefined,
name: values.text,
contentType: values.contenttype || webDavFile.contentType,
}) as WebDavFile;
}
}

function getWebDavUrl(containerPath: string, directory?: string, createIntermediates?: boolean) {
function getWebDavUrl(containerPath: string, directory?: string, createIntermediates?: boolean, skipAtFiles?: boolean) {
let url =
ActionURL.getContextPath() +
'/_webdav' +
ActionURL.encodePath(containerPath) +
'/' +
encodeURIComponent('@files');
(!skipAtFiles ? '/' + encodeURIComponent('@files') : '');

if (directory) {
url += '/' + directory;
Expand All @@ -62,10 +64,11 @@ function getWebDavUrl(containerPath: string, directory?: string, createIntermedi
export function getWebDavFiles(
containerPath: string,
directory?: string,
includeDirectories?: boolean
includeDirectories?: boolean,
skipAtFiles?: boolean
): Promise<Map<string, WebDavFile>> {
return new Promise((resolve, reject) => {
const url = getWebDavUrl(containerPath, directory);
const url = getWebDavUrl(containerPath, directory, false, skipAtFiles);

return Ajax.request({
url: url + '?method=JSON',
Expand Down
Loading