Skip to content

Commit

Permalink
FileTree and WebDav updates to support module editor scenario (#259)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
cnathe authored Jun 2, 2020
1 parent 152597d commit 7a8d783
Show file tree
Hide file tree
Showing 10 changed files with 991 additions and 178 deletions.
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 => {
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

0 comments on commit 7a8d783

Please sign in to comment.