folders ui bug fix and test prep

folders^2
gabrielburnworth 2019-12-20 08:17:55 -08:00
parent 4b12828262
commit fa7fc0024e
5 changed files with 136 additions and 251 deletions

View File

@ -297,6 +297,11 @@
.fa-arrows-v, .fa-ellipsis-v {
display: none;
}
.fa-ellipsis-v {
&.open {
display: block;
}
}
&:hover {
.fa-arrows-v, .fa-ellipsis-v {
display: block;

View File

@ -0,0 +1,24 @@
import * as React from "react";
import { mount } from "enzyme";
import { Folders } from "../component";
import { FolderProps } from "../constants";
describe("<Folders />", () => {
const fakeProps = (): FolderProps => ({
rootFolder: {
folders: [],
noFolder: [],
},
sequences: {},
searchTerm: undefined,
dispatch: Function,
resourceUsage: {},
sequenceMetas: {},
});
it("renders empty state", () => {
const p = fakeProps();
const wrapper = mount<Folders>(<Folders {...p} />);
expect(wrapper.text()).toContain("No Sequences.");
});
});

View File

@ -4,7 +4,7 @@ import {
EmptyStateWrapper,
EmptyStateGraphic,
Saucer,
ColorPicker
ColorPicker,
} from "../ui";
import {
FolderUnion,
@ -14,7 +14,9 @@ import {
FolderState,
AddFolderBtn,
AddSequenceProps,
ToggleFolderBtnProps
ToggleFolderBtnProps,
FolderNodeState,
FolderPanelTopProps,
} from "./constants";
import {
createFolder,
@ -26,7 +28,7 @@ import {
updateSearchTerm,
addNewSequenceToFolder,
moveSequence,
setFolderColor
setFolderColor,
} from "./actions";
import { Link } from "../link";
import { urlFriendly, lastUrlChunk } from "../util";
@ -39,7 +41,7 @@ import { Content } from "../constants";
import { StepDragger, NULL_DRAGGER_ID } from "../draggable/step_dragger";
import { variableList } from "../sequences/locals_list/variable_support";
const FolderListItem = (props: FolderItemProps) => {
export const FolderListItem = (props: FolderItemProps) => {
const { sequence, onClick } = props;
const seqName = sequence.body.name;
const url = `/app/sequences/${urlFriendly(seqName) || ""}`;
@ -56,23 +58,24 @@ const FolderListItem = (props: FolderItemProps) => {
intent="step_splice"
draggerId={NULL_DRAGGER_ID}>
<Link to={url} key={sequence.uuid} onClick={setActiveSequenceByName}>
<li className={`sequence-list-item ${active} ${moveTarget}`} draggable={true}>
<li className={`sequence-list-item ${active} ${moveTarget}`}
draggable={true}>
<Saucer color={sequence.body.color || "gray"} active={false} />
<p>{nameWithSaveIndicator}</p>
<div className="sequence-list-item-icons">
{props.inUse &&
<i className="in-use fa fa-hdd-o" title={t(Content.IN_USE)} />}
<i className="fa fa-arrows-v" onClick={() => onClick(sequence.uuid)} />
<i className="fa fa-arrows-v"
onClick={() => onClick(sequence.uuid)} />
</div>
</li>
</Link>
</StepDragger>;
};
const ToggleFolderBtn = (p: ToggleFolderBtnProps) => {
const klass = `fa fa-${p.expanded ? "plus" : "minus"}-square`;
return <button className="fb-button gray" onClick={p.onClick}>
<i className={klass} />
const ToggleFolderBtn = (props: ToggleFolderBtnProps) => {
return <button className="fb-button gray" onClick={props.onClick}>
<i className={`fa fa-${props.expanded ? "plus" : "minus"}-square`} />
</button>;
};
@ -98,7 +101,7 @@ const AddSequenceBtn = ({ folderId }: AddSequenceProps) => {
</button>;
};
const FolderButtonCluster = ({ node }: FolderNodeProps) => {
export const FolderButtonCluster = ({ node }: FolderNodeProps) => {
return <div className="folder-button-cluster">
<button
className="fb-button red"
@ -116,41 +119,50 @@ const FolderButtonCluster = ({ node }: FolderNodeProps) => {
</div>;
};
const FolderNameEditor = (props: FolderNodeProps) => {
const { node } = props;
const moveModeTarget = props.movedSequenceUuid ? "move-target" : "";
const nodeName = moveModeTarget ? t("CLICK TO MOVE HERE") : node.name;
const onClick = () => moveModeTarget ? props.onMoveEnd(node.id) : undefined;
const toggle = () => moveModeTarget ? undefined : toggleFolderOpenState(node.id);
return <div className={`folder-list-item ${moveModeTarget}`}
onClick={onClick}>
<i className={`fa fa-chevron-${node.open ? "down" : "right"}`}
title={"Open/Close Folder"}
onClick={toggle} />
<ColorPicker
saucerIcon={"fa-folder"}
current={node.color}
onChange={color => setFolderColor(node.id, color)} />
<div className="folder-name" onClick={toggle}>
{node.editing
? <BlurableInput value={nodeName} onCommit={e =>
setFolderName(node.id, e.currentTarget.value)
.then(() => toggleFolderEditState(node.id))} />
: <p>{nodeName}</p>}
</div>
<Popover className="folder-settings-icon" usePortal={false}>
<i className={"fa fa-ellipsis-v"} />
<FolderButtonCluster {...props} />
</Popover>
</div>;
};
export class FolderNameEditor
extends React.Component<FolderNodeProps, FolderNodeState> {
state: FolderNodeState = { settingsOpen: false };
render() {
const { node } = this.props;
const moveModeTarget = this.props.movedSequenceUuid ? "move-target" : "";
const settingsOpenClass = this.state.settingsOpen ? "open" : "";
const nodeName = moveModeTarget ? t("CLICK TO MOVE HERE") : node.name;
const onClick = () =>
moveModeTarget ? this.props.onMoveEnd(node.id) : undefined;
const toggle = () =>
moveModeTarget ? undefined : toggleFolderOpenState(node.id);
return <div className={`folder-list-item ${moveModeTarget}`}
onClick={onClick}>
<i className={`fa fa-chevron-${node.open ? "down" : "right"}`}
title={"Open/Close Folder"}
onClick={toggle} />
<ColorPicker
saucerIcon={"fa-folder"}
current={node.color}
onChange={color => setFolderColor(node.id, color)} />
<div className="folder-name" onClick={toggle}>
{node.editing
? <BlurableInput value={nodeName} onCommit={e =>
setFolderName(node.id, e.currentTarget.value)
.then(() => toggleFolderEditState(node.id))} />
: <p>{nodeName}</p>}
</div>
<Popover className="folder-settings-icon" usePortal={false}
isOpen={this.state.settingsOpen}>
<i className={`fa fa-ellipsis-v ${settingsOpenClass}`}
onClick={() =>
this.setState({ settingsOpen: !this.state.settingsOpen })} />
<FolderButtonCluster {...this.props} />
</Popover>
</div>;
}
}
const FolderNode = (props: FolderNodeProps) => {
const { node, sequences } = props;
const names = node
.content
.map(seqUuid => <FolderListItem
const sequenceItems = node.content.map(seqUuid =>
<FolderListItem
sequence={sequences[seqUuid]}
key={"F" + seqUuid}
dispatch={props.dispatch}
@ -159,29 +171,30 @@ const FolderNode = (props: FolderNodeProps) => {
onClick={props.onMoveStart}
isMoveTarget={props.movedSequenceUuid === seqUuid} />);
const mapper = (n2: FolderUnion) => <FolderNode
node={n2}
key={n2.id}
sequences={sequences}
dispatch={props.dispatch}
sequenceMetas={props.sequenceMetas}
resourceUsage={props.resourceUsage}
movedSequenceUuid={props.movedSequenceUuid}
onMoveStart={props.onMoveStart}
onMoveEnd={props.onMoveEnd} />;
const array: FolderUnion[] = node.children || [];
const childFolders: FolderUnion[] = node.children || [];
const folderNodes = childFolders.map(folder =>
<FolderNode
node={folder}
key={folder.id}
sequences={sequences}
dispatch={props.dispatch}
sequenceMetas={props.sequenceMetas}
resourceUsage={props.resourceUsage}
movedSequenceUuid={props.movedSequenceUuid}
onMoveStart={props.onMoveStart}
onMoveEnd={props.onMoveEnd} />);
return <div className="folder">
<FolderNameEditor {...props} />
{!!node.open && <ul className="in-folder-sequences">{names}</ul>}
{!!node.open && array.map(mapper)}
{!!node.open && <ul className="in-folder-sequences">{sequenceItems}</ul>}
{!!node.open && folderNodes}
</div>;
};
export class Folders extends React.Component<FolderProps, FolderState> {
state: FolderState = { toggleDirection: false };
Graph = (_props: {}) => {
Graph = () => {
return <div className="folders">
{this.props.rootFolder.folders.map(grandparent => {
return <FolderNode
@ -212,11 +225,8 @@ export class Folders extends React.Component<FolderProps, FolderState> {
this.setState({ movedSequenceUuid: undefined });
}
rootSequences = () => this
.props
.rootFolder
.noFolder
.map(seqUuid => <FolderListItem
rootSequences = () => this.props.rootFolder.noFolder.map(seqUuid =>
<FolderListItem
key={seqUuid}
dispatch={this.props.dispatch}
variableData={this.props.sequenceMetas[seqUuid]}
@ -227,25 +237,10 @@ export class Folders extends React.Component<FolderProps, FolderState> {
render() {
return <div className="folders-panel">
<div className="panel-top with-button">
<div className="thin-search-wrapper">
<div className="text-input-wrapper">
<i className="fa fa-search" />
<input
value={this.props.searchTerm || ""}
onChange={({ currentTarget }) => {
updateSearchTerm(currentTarget.value);
}}
type="text"
placeholder={t("Search sequences")} />
</div>
</div>
<ToggleFolderBtn
expanded={this.state.toggleDirection}
onClick={this.toggleAll} />
<AddFolderBtn />
<AddSequenceBtn />
</div>
<FolderPanelTop
searchTerm={this.props.searchTerm}
toggleDirection={this.state.toggleDirection}
toggleAll={this.toggleAll} />
<EmptyStateWrapper
notEmpty={Object.values(this.props.sequences).length > 0
|| this.props.rootFolder.folders.length > 0}
@ -260,3 +255,24 @@ export class Folders extends React.Component<FolderProps, FolderState> {
</div>;
}
}
export const FolderPanelTop = (props: FolderPanelTopProps) =>
<div className="panel-top with-button">
<div className="thin-search-wrapper">
<div className="text-input-wrapper">
<i className="fa fa-search" />
<input
value={props.searchTerm || ""}
onChange={({ currentTarget }) => {
updateSearchTerm(currentTarget.value);
}}
type="text"
placeholder={t("Search sequences")} />
</div>
</div>
<ToggleFolderBtn
expanded={props.toggleDirection}
onClick={props.toggleAll} />
<AddFolderBtn />
<AddSequenceBtn />
</div>;

View File

@ -66,11 +66,21 @@ export interface FolderProps {
sequenceMetas: Record<UUID, VariableNameSet | undefined>;
}
export interface FolderNodeState {
settingsOpen: boolean;
}
export interface FolderState {
toggleDirection: boolean;
movedSequenceUuid?: string;
}
export interface FolderPanelTopProps {
searchTerm: string | undefined;
toggleDirection: boolean;
toggleAll(): void;
}
export interface FolderNodeProps {
node: FolderUnion;
sequences: Record<string, TaggedSequence>;

View File

@ -1,170 +0,0 @@
import * as React from "react";
import { push } from "../history";
import { SequencesListProps, SequencesListState } from "./interfaces";
import { sortResourcesById, urlFriendly, lastUrlChunk } from "../util";
import { Row, Col } from "../ui/index";
import { TaggedSequence } from "farmbot";
import { init, destroy } from "../api/crud";
import { Content } from "../constants";
import { StepDragger, NULL_DRAGGER_ID } from "../draggable/step_dragger";
import { Link } from "../link";
import { setActiveSequenceByName } from "./set_active_sequence_by_name";
import { UUID, VariableNameSet } from "../resources/interfaces";
import { variableList } from "./locals_list/variable_support";
import { t } from "../i18next_wrapper";
import { EmptyStateWrapper, EmptyStateGraphic } from "../ui/empty_state_wrapper";
import { DevSettings } from "../account/dev/dev_support";
const filterFn = (searchTerm: string) => (seq: TaggedSequence): boolean =>
seq.body.name.toLowerCase().includes(searchTerm);
interface SequenceButtonWrapperProps {
ts: TaggedSequence;
dispatch: Function;
variableData: VariableNameSet | undefined;
children: React.ReactChild;
}
/** Sequence list item wrapper for drag action and link to sequence. */
const SequenceButtonWrapper = (props: SequenceButtonWrapperProps) =>
<div className="sequence-list-item" key={props.ts.uuid}>
<StepDragger
dispatch={props.dispatch}
step={{
kind: "execute",
args: { sequence_id: props.ts.body.id || 0 },
body: variableList(props.variableData)
}}
intent="step_splice"
draggerId={NULL_DRAGGER_ID}>
<Link
to={`/app/sequences/${urlFriendly(props.ts.body.name) || ""}`}
key={props.ts.uuid}
onClick={setActiveSequenceByName}>
{props.children}
</Link>
</StepDragger>
</div>;
interface SequenceButtonProps {
ts: TaggedSequence;
inUse: boolean;
deleteFunc?: () => void;
}
/** Sequence list item label and indicators. */
const SequenceButton = (props: SequenceButtonProps) => {
const { color, name } = props.ts.body;
const css = [`fb-button`, `block`, `full-width`, `${color || "purple"}`];
lastUrlChunk() === urlFriendly(name) && css.push("active");
props.deleteFunc && css.push("quick-del");
const nameWithSaveIndicator = name + (props.ts.specialStatus ? "*" : "");
return <button className={css.join(" ")} draggable={true}
onClick={props.deleteFunc}>
<label>{nameWithSaveIndicator}</label>
{props.inUse &&
<i className="in-use fa fa-hdd-o" title={t(Content.IN_USE)} />}
</button>;
};
interface SequenceListItemProps {
dispatch: Function;
resourceUsage: Record<UUID, boolean | undefined>;
sequenceMetas: Record<UUID, VariableNameSet | undefined>;
}
const SequenceListItem = (props: SequenceListItemProps) =>
(ts: TaggedSequence) => {
const inUse = !!props.resourceUsage[ts.uuid];
const variableData = props.sequenceMetas[ts.uuid];
const deleteSeq = () => props.dispatch(destroy(ts.uuid));
return <div className="sequence-list-item" key={ts.uuid}>
{DevSettings.quickDeleteEnabled()
? <SequenceButton ts={ts} inUse={inUse} deleteFunc={deleteSeq} />
: <SequenceButtonWrapper
ts={ts} dispatch={props.dispatch} variableData={variableData}>
<SequenceButton ts={ts} inUse={inUse} />
</SequenceButtonWrapper>}
</div>;
};
const emptySequenceBody = (seqCount: number): TaggedSequence["body"] => ({
name: t("new sequence {{ num }}", { num: seqCount }),
folder_id: undefined,
args: {
version: -999,
locals: { kind: "scope_declaration", args: {} },
},
color: "gray",
kind: "sequence",
body: []
});
interface SequenceListHeaderProps {
onChange(e: React.SyntheticEvent<HTMLInputElement>): void;
sequenceCount: number;
dispatch: Function;
}
const SequenceListHeader = (props: SequenceListHeaderProps) =>
<div className={"panel-top with-button"}>
<div className="thin-search-wrapper">
<div className="text-input-wrapper">
<i className="fa fa-search"></i>
<input
onChange={props.onChange}
placeholder={t("Search Sequences...")} />
</div>
</div>
<button
className="fb-button green add"
onClick={() => {
const newSequence = emptySequenceBody(props.sequenceCount);
props.dispatch(init("Sequence", newSequence));
push("/app/sequences/" + urlFriendly(newSequence.name));
setActiveSequenceByName();
}}>
<i className="fa fa-plus" />
</button>
</div>;
export class SequencesList extends
React.Component<SequencesListProps, SequencesListState> {
state: SequencesListState = {
searchTerm: ""
};
onChange = (e: React.SyntheticEvent<HTMLInputElement>) =>
this.setState({ searchTerm: e.currentTarget.value });
render() {
const { sequences, dispatch, resourceUsage, sequenceMetas } = this.props;
const searchTerm = this.state.searchTerm.toLowerCase();
return <div>
<SequenceListHeader
dispatch={dispatch}
sequenceCount={this.props.sequences.length}
onChange={this.onChange} />
<Row>
<Col xs={12}>
<EmptyStateWrapper
notEmpty={sequences.length > 0}
graphic={EmptyStateGraphic.sequences}
title={t("No Sequences.")}
text={Content.NO_SEQUENCES}>
{sequences.length > 0 &&
<div className="sequence-list">
{sortResourcesById(sequences)
.filter(filterFn(searchTerm))
.map(SequenceListItem({
dispatch, resourceUsage, sequenceMetas
}))}
</div>}
</EmptyStateWrapper>
</Col>
</Row>
</div>;
}
}