folder ui updates

folders
gabrielburnworth 2019-12-19 17:30:02 -08:00
parent f9ac3e659f
commit a2ae2ea38e
13 changed files with 355 additions and 133 deletions

View File

@ -3,6 +3,7 @@ $translucent: rgba(0, 0, 0, 0.2);
$translucent2: rgba(0, 0, 0, 0.6);
$white: #fff;
$off_white: #f4f4f4;
$lighter_gray: #eee;
$light_gray: #ddd;
$gray: #ccc;
$medium_light_gray: #bcbcbc;
@ -129,9 +130,36 @@ $panel_light_red: #fff7f6;
background: $blue !important;
}
.dark-blue,
.fun,
.saucer-fun {
background: $dark_blue !important;
}
.icon-saucer {
background: none !important;
&.blue {
color: $blue;
}
&.green {
color: $green;
}
&.yellow {
color: $yellow;
}
&.orange {
color: $orange;
}
&.purple {
color: $purple;
}
&.pink {
color: $pink;
}
&.gray {
color: $gray;
}
&.red {
color: $red;
}
}

View File

@ -394,7 +394,7 @@
a {
margin-top: 0.5rem;
}
i {
i &:not(.fa-stack-2x) {
font-size: 1.5rem;
}
}

View File

@ -34,10 +34,29 @@ body {
width: 13rem;
background: $dark_gray;
}
div {
.bp3-popover-content,
.color-picker-cluster,
.color-picker-item-wrapper,
.saucer {
display: inline-block;
padding: 0.4rem;
}
.color-picker-item {
position: relative;
.active-border {
display: none;
}
&.active {
.active-border {
display: block;
position: absolute;
top: 2px;
left: 1px;
transform: scale(1.5);
color: $white;
}
}
}
}
.bp3-popover.help {
@ -113,6 +132,21 @@ fieldset {
}
}
.icon-saucer {
position: relative;
z-index: 2;
height: 2rem;
width: 2rem;
color: $dark_gray;
cursor: pointer;
&.active {
border: 2px solid white;
}
&.hover {
border: 2px solid $dark_gray;
}
}
.saucer-connector {
position: absolute;
z-index: 1;

View File

@ -229,8 +229,145 @@
}
}
.sequence-list-item {
margin-right: 15px;
.folders-panel {
.panel-top,
.folder-button-cluster {
i {
font-size: 1.5rem;
}
.fa-stack {
font-size: 1rem;
transform: scale(0.8);
}
.fa-stack-2x {
font-size: 2rem;
}
.fa-stack-1x {
font-size: 1.5rem;
line-height: 1.5rem;
margin-left: 0.75rem;
filter: drop-shadow(0 0 0.2rem $dark_green);
text-align: center;
}
}
.non-empty-state {
margin-left: -30px;
margin-right: -20px;
}
@media screen and (max-width: 767px) {
.non-empty-state {
margin-left: -15px;
}
}
.folder-button-cluster {
display: flex;
i {
width: 1.5rem !important;
}
}
ul {
margin-bottom: 0;
}
.folders {
.folder > div:not(:first-child), ul {
margin-left: 1rem;
}
}
.folder-list-item,
.sequence-list-item {
display: flex;
position: relative;
width: 100%;
border-bottom: 1px solid $light_gray;
padding: 0.5rem;
cursor: pointer;
background: $lighter_gray;
&.active {
border-left: 3px solid $dark_gray;
}
.fa-chevron-down, .fa-chevron-right {
z-index: 2;
width: 2rem;
font-size: 1.1rem;
}
.folder-settings-icon,
.fa-arrows-v {
position: absolute;
right: 0;
}
.fa-arrows-v, .fa-ellipsis-v {
display: none;
}
&:hover {
.fa-arrows-v, .fa-ellipsis-v {
display: block;
}
}
i {
margin: 0;
line-height: 2.5rem;
width: 3rem;
text-align: center;
}
.saucer, .icon-saucer {
position: absolute;
margin: 0.5rem;
width: 1.2rem;
height: 1.2rem;
}
p {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 1.2rem;
font-weight: bold;
width: 100%;
padding: 0.5rem;
margin-left: 2rem;
}
.folder-name {
width: 100%;
margin-right: 3rem;
.input {
width: 90%;
margin-left: 2rem;
}
}
.sequence-list-item-icons {
display: flex;
margin-right: 3rem;
}
button {
margin-left: 0.5rem;
}
&.move-source {
border: 1px solid $dark_gray;
}
&.move-target {
background: $white;
}
}
.sequence-list-item {
padding-left: 2rem;
.saucer {
top: 0.55rem;
}
}
.folder-list-item {
.bp3-popover-wrapper {
position: absolute;
}
.color-picker {
.bp3-popover-target {
margin-left: 2.5rem;
}
.icon-saucer {
top: 0;
left: 1.5rem;
line-height: 1.4rem;
}
}
}
}
.farmware-list-panel,

View File

@ -32,7 +32,7 @@ export function StepDragger({ dispatch,
children,
intent,
draggerId }: StepDraggerProps) {
return <div
return <div className="step-dragger"
onDragStart={stepDragEventHandler(dispatch,
step,
intent,

View File

@ -1,20 +1,23 @@
import { mapStateToFolderProps } from "../map_state_to_props";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { fakeFolder, fakeSequence } from "../../__test_support__/fake_state/resources";
import { fakeState } from "../../__test_support__/fake_state";
describe("mapStateToFolderProps", () => {
it("maps state to props", () => {
const f1 = fakeFolder({ name: "@" });
const f2 = fakeFolder({ name: "#", parent_id: f1.body.id });
const f3 = fakeFolder({ name: "$", parent_id: f2.body.id });
const props = mapStateToFolderProps(buildResourceIndex([f1,
const state = fakeState();
state.resources = buildResourceIndex([f1,
f2,
f3,
fakeSequence({ name: "%", folder_id: f1.body.id }),
fakeSequence({ name: "^", folder_id: f2.body.id }),
fakeSequence({ name: "&", folder_id: f3.body.id }),
fakeSequence({ name: "*", folder_id: undefined }),
fakeSequence({ name: "!", folder_id: undefined })]));
fakeSequence({ name: "!", folder_id: undefined })]);
const props = mapStateToFolderProps(state);
expect(props).toBeDefined();
expect(props.rootFolder.folders.length).toBe(1);
expect(props.rootFolder.noFolder.length).toBe(2);

View File

@ -1,10 +1,10 @@
import React from "react";
import {
BlurableInput,
Row,
Col,
ColorPickerCluster,
Saucer
EmptyStateWrapper,
EmptyStateGraphic,
Saucer,
ColorPicker
} from "../ui";
import {
FolderUnion,
@ -29,40 +29,44 @@ import {
setFolderColor
} from "./actions";
import { Link } from "../link";
import { urlFriendly } from "../util";
import { urlFriendly, lastUrlChunk } from "../util";
import {
setActiveSequenceByName
} from "../sequences/set_active_sequence_by_name";
import { Popover, Position } from "@blueprintjs/core";
import { Popover } from "@blueprintjs/core";
import { t } from "../i18next_wrapper";
type Style = React.StyleHTMLAttributes<HTMLDivElement>["style"];
const FOLDER_LIST_ITEM: Style = {
backgroundColor: "#ddd",
borderBottom: "1px solid #aaa",
padding: "0.5rem",
cursor: "pointer",
height: "3.5rem"
};
const UL_STYLE = { marginBottom: "0px" };
const FLEX = { display: "flex" };
const FOLDER_NODE_WRAPPER = { marginLeft: 10 };
const FOLDER_PANEL_WRAPPER = { marginTop: 0 };
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) => {
const { sequence, onClick } = props;
const url = `/app/sequences/${urlFriendly(sequence.body.name) || ""}`;
const style = props.isMoveTarget ? { border: "1px solid red" } : {};
return <li style={{ ...style, ...FOLDER_LIST_ITEM }}>
<i onClick={() => onClick(sequence.uuid)} className="fa fa-arrows-v float-right" />
<div className={"float-left"}>
<Saucer color={sequence.body.color || "gray"} active={false} />
</div>
const seqName = sequence.body.name;
const url = `/app/sequences/${urlFriendly(seqName) || ""}`;
const moveTarget = props.isMoveTarget ? "move-source" : "";
const nameWithSaveIndicator = seqName + (sequence.specialStatus ? "*" : "");
const active = lastUrlChunk() === urlFriendly(seqName) ? "active" : "";
return <StepDragger
dispatch={props.dispatch}
step={{
kind: "execute",
args: { sequence_id: props.sequence.body.id || 0 },
body: variableList(props.variableData)
}}
intent="step_splice"
draggerId={NULL_DRAGGER_ID}>
<Link to={url} key={sequence.uuid} onClick={setActiveSequenceByName}>
{sequence.body.name}
<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)} />
</div>
</li>
</Link>
</li>;
</StepDragger>;
};
const ToggleFolderBtn = (p: ToggleFolderBtnProps) => {
@ -76,9 +80,10 @@ const AddFolderBtn = ({ folder }: AddFolderBtn) => {
return <button
className="fb-button green"
onClick={() => createFolder(folder || {})}>
<i
title={"Create Subfolder"}
className="fa fa-folder" />
<div className="fa-stack fa-2x" title={"Create Subfolder"}>
<i className="fa fa-folder fa-stack-2x" />
<i className="fa fa-plus fa-stack-1x" />
</div>
</button>;
};
@ -86,12 +91,15 @@ const AddSequenceBtn = ({ folderId }: AddSequenceProps) => {
return <button
className="fb-button green"
onClick={() => addNewSequenceToFolder(folderId)}>
<i className="fa fa-server" />
<div className="fa-stack fa-2x">
<i className="fa fa-server fa-stack-2x" />
<i className="fa fa-plus fa-stack-1x" />
</div>
</button>;
};
const FolderButtonCluster = ({ node }: FolderNodeProps) => {
return <div style={FLEX}>
return <div className="folder-button-cluster">
<button
className="fb-button red"
onClick={() => deleteFolder(node.id)}>
@ -110,51 +118,29 @@ const FolderButtonCluster = ({ node }: FolderNodeProps) => {
const FolderNameEditor = (props: FolderNodeProps) => {
const { node } = props;
const onCommit = (e: React.SyntheticEvent<HTMLInputElement, Event>) => {
const { currentTarget } = e;
return setFolderName(node.id, currentTarget.value)
.then(() => toggleFolderEditState(node.id));
};
let namePart: JSX.Element;
const toggle = () => toggleFolderOpenState(node.id);
const nodeName = props.movedSequenceUuid ?
t("CLICK TO MOVE HERE") : node.name;
if (node.editing) {
namePart = <BlurableInput value={nodeName} onCommit={onCommit} />;
} else {
namePart = <span onClick={toggle}> {nodeName}</span>;
}
const faIcon = ` fa fa-chevron-${node.open ? "down" : "right"}`;
const style = {
...FOLDER_LIST_ITEM,
...(props.movedSequenceUuid ? { backgroundColor: "#bbb" } : {})
};
const onClick =
props.movedSequenceUuid ? () => props.onMoveEnd(node.id) : () => { };
return <div style={style} onClick={onClick}>
<i
className={"float-left" + faIcon}
const moveModeTarget = props.movedSequenceUuid ? "move-target" : "";
const nodeName = moveModeTarget ? t("CLICK TO MOVE HERE") : node.name;
const onClick = moveModeTarget ? () => props.onMoveEnd(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} />
<div className={"float-left"}>
<Popover
position={Position.BOTTOM}
popoverClassName="colorpicker-menu gray">
<i className="fa fa-folder" style={{ color: node.color }} />
<ColorPickerCluster
current={node.color}
onChange={(color) => setFolderColor(node.id, color)} />
</Popover>
</div>
{namePart}
<div className={"float-right"}>
<Popover>
<i className={"fa fa-ellipsis-v"} />
<FolderButtonCluster {...props} />
</Popover>
onClick={() => toggleFolderOpenState(node.id)} />
<ColorPicker
saucerIcon={"fa-folder"}
current={node.color}
onChange={color => setFolderColor(node.id, color)} />
<div className="folder-name" onClick={() => toggleFolderOpenState(node.id)}>
{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>;
};
@ -163,28 +149,29 @@ const FolderNode = (props: FolderNodeProps) => {
const names = node
.content
.map(x => <FolderListItem
sequence={sequences[x]}
key={"F" + x}
.map(seqUuid => <FolderListItem
sequence={sequences[seqUuid]}
key={"F" + seqUuid}
dispatch={props.dispatch}
variableData={props.sequenceMetas[seqUuid]}
inUse={!!props.resourceUsage[seqUuid]}
onClick={props.onMoveStart}
isMoveTarget={props.movedSequenceUuid === x} />);
isMoveTarget={props.movedSequenceUuid === seqUuid} />);
const children = <ul style={UL_STYLE}> {names} </ul>;
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 || [];
return <div style={FOLDER_NODE_WRAPPER}>
<Row>
<Col xs={12}>
<FolderNameEditor {...props} />
</Col>
</Row>
{!!node.open && children}
return <div className="folder">
<FolderNameEditor {...props} />
{!!node.open && <ul className="in-folder-sequences">{names}</ul>}
{!!node.open && array.map(mapper)}
</div>;
};
@ -194,11 +181,14 @@ export class Folders extends React.Component<FolderProps, FolderState> {
Graph = (_props: {}) => {
return <div>
return <div className="folders">
{this.props.rootFolder.folders.map(grandparent => {
return <FolderNode
node={grandparent}
key={grandparent.id}
dispatch={this.props.dispatch}
sequenceMetas={this.props.sequenceMetas}
resourceUsage={this.props.resourceUsage}
movedSequenceUuid={this.state.movedSequenceUuid}
onMoveStart={this.startSequenceMove}
onMoveEnd={this.endSequenceMove}
@ -225,15 +215,18 @@ export class Folders extends React.Component<FolderProps, FolderState> {
.props
.rootFolder
.noFolder
.map(x => <FolderListItem
key={x}
sequence={this.props.sequences[x]}
.map(seqUuid => <FolderListItem
key={seqUuid}
dispatch={this.props.dispatch}
variableData={this.props.sequenceMetas[seqUuid]}
inUse={!!this.props.resourceUsage[seqUuid]}
sequence={this.props.sequences[seqUuid]}
onClick={this.startSequenceMove}
isMoveTarget={this.state.movedSequenceUuid === x} />);
isMoveTarget={this.state.movedSequenceUuid === seqUuid} />);
render() {
return <div>
<div className="panel-top with-button" style={FOLDER_PANEL_WRAPPER}>
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" />
@ -252,10 +245,17 @@ export class Folders extends React.Component<FolderProps, FolderState> {
<AddFolderBtn />
<AddSequenceBtn />
</div>
<ul style={UL_STYLE}>
{this.rootSequences()}
</ul>
<this.Graph />
<EmptyStateWrapper
notEmpty={Object.values(this.props.sequences).length > 0
|| this.props.rootFolder.folders.length > 0}
graphic={EmptyStateGraphic.sequences}
title={t("No Sequences.")}
text={Content.NO_SEQUENCES}>
<ul className="sequences-not-in-folders">
{this.rootSequences()}
</ul>
<this.Graph />
</EmptyStateWrapper>
</div>;
}
}

View File

@ -2,6 +2,7 @@ import { Color } from "farmbot/dist/corpus";
import { TaggedSequence } from "farmbot";
import { DeepPartial } from "redux";
import { Folder } from "farmbot/dist/resources/api_resources";
import { VariableNameSet, UUID } from "../resources/interfaces";
export interface FolderMeta {
open: boolean;
@ -60,6 +61,9 @@ export interface FolderProps {
rootFolder: RootFolderNode;
sequences: Record<string, TaggedSequence>;
searchTerm: string | undefined;
dispatch: Function;
resourceUsage: Record<UUID, boolean | undefined>;
sequenceMetas: Record<UUID, VariableNameSet | undefined>;
}
export interface FolderState {
@ -73,12 +77,18 @@ export interface FolderNodeProps {
movedSequenceUuid: string | undefined;
onMoveStart(sequenceUuid: string): void;
onMoveEnd(folderId: number): void;
dispatch: Function;
resourceUsage: Record<UUID, boolean | undefined>;
sequenceMetas: Record<UUID, VariableNameSet | undefined>;
}
export interface FolderItemProps {
onClick(sequenceUuid: string): void;
sequence: TaggedSequence;
isMoveTarget: boolean;
dispatch: Function;
variableData: VariableNameSet | undefined;
inUse: boolean;
}
export interface AddFolderBtn {

View File

@ -1,7 +1,8 @@
import { FolderProps } from "./constants";
import { selectAllSequences } from "../resources/selectors";
import { TaggedSequence } from "farmbot";
import { RestResources } from "../resources/interfaces";
import { resourceUsageList } from "../resources/in_use";
import { Everything } from "../interfaces";
type SequenceDict = Record<string, TaggedSequence>;
type Reducer = (a: FolderProps["sequences"], b: TaggedSequence) => SequenceDict;
@ -10,12 +11,15 @@ const reduce: Reducer = (a, b) => {
return a;
};
export function mapStateToFolderProps(props: RestResources): FolderProps {
const x = props.index.sequenceFolders;
export function mapStateToFolderProps(props: Everything): FolderProps {
const x = props.resources.index.sequenceFolders;
return {
rootFolder: x.filteredFolders ? x.filteredFolders : x.folders,
sequences: selectAllSequences(props.index).reduce(reduce, {}),
searchTerm: x.searchTerm
sequences: selectAllSequences(props.resources.index).reduce(reduce, {}),
searchTerm: x.searchTerm,
dispatch: props.dispatch,
sequenceMetas: props.resources.index.sequenceMetas,
resourceUsage: resourceUsageList(props.resources.index.inUse),
};
}

View File

@ -39,7 +39,7 @@ describe("<Sequences/>", () => {
getWebAppConfigValue: jest.fn(),
menuOpen: false,
stepIndex: undefined,
folderData: mapStateToFolderProps(fakeState().resources)
folderData: mapStateToFolderProps(fakeState())
});
it("renders", () => {

View File

@ -2,12 +2,10 @@ import * as React from "react";
import { connect } from "react-redux";
import { StepButtonCluster } from "./step_button_cluster";
import { SequenceEditorMiddle } from "./sequence_editor_middle";
import {
Page, Row, LeftPanel, EmptyStateWrapper, EmptyStateGraphic
} from "../ui";
import { Page, Row, LeftPanel } from "../ui";
import { Props } from "./interfaces";
import { mapStateToProps } from "./state_to_props";
import { ToolTips, Content } from "../constants";
import { ToolTips } from "../constants";
import { isTaggedSequence } from "../resources/tagged_resources";
import { setActiveSequenceByName } from "./set_active_sequence_by_name";
import { CenterPanel, RightPanel } from "../ui";
@ -47,14 +45,7 @@ export class RawSequences extends React.Component<Props, {}> {
className={`sequence-list-panel ${activeClasses}`}
title={t("Sequences")}
helpText={t(ToolTips.SEQUENCE_LIST)}>
<EmptyStateWrapper
notEmpty={this.props.sequences.length > 0
|| this.props.folderData.rootFolder.folders.length > 0}
graphic={EmptyStateGraphic.sequences}
title={t("No Sequences.")}
text={Content.NO_SEQUENCES}>
<Folders {...this.props.folderData} />
</EmptyStateWrapper>
<Folders {...this.props.folderData} dispatch={this.props.dispatch} />
</LeftPanel>
<CenterPanel
className={`sequence-editor-panel ${activeClasses}`}

View File

@ -89,6 +89,6 @@ export function mapStateToProps(props: Everything): Props {
getWebAppConfigValue: getConfig,
menuOpen: props.resources.consumers.sequences.menuOpen,
stepIndex: props.resources.consumers.sequences.stepIndex,
folderData: mapStateToFolderProps(props.resources)
folderData: mapStateToFolderProps(props)
};
}

View File

@ -8,11 +8,13 @@ interface PickerProps {
position?: Position;
current: ResourceColor;
onChange?: (color: ResourceColor) => void;
saucerIcon?: string;
}
interface ColorPickerClusterProps {
onChange: (color: ResourceColor) => void;
current: ResourceColor;
saucerIcon?: string;
}
interface ColorPickerItemProps extends ColorPickerClusterProps {
@ -21,17 +23,24 @@ interface ColorPickerItemProps extends ColorPickerClusterProps {
const ColorPickerItem = (props: ColorPickerItemProps) => {
const isActive = props.color === props.current;
return <div onClick={() => props.onChange(props.color)}>
<Saucer color={props.color} active={isActive} />
return <div className="color-picker-item-wrapper"
onClick={() => props.onChange(props.color)}>
{props.saucerIcon
? <div className={`color-picker-item ${isActive ? "active" : ""}`}>
<i className={`icon-saucer active-border fa ${props.saucerIcon}`} />
<i className={`icon-saucer fa ${props.saucerIcon} ${props.color}`} />
</div>
: <Saucer color={props.color} active={isActive} />}
</div>;
};
export const ColorPickerCluster = (props: ColorPickerClusterProps) => {
return <div>
return <div className="color-picker-cluster">
{colors.map((color) => {
return <ColorPickerItem
key={color}
onChange={props.onChange}
saucerIcon={props.saucerIcon}
current={props.current}
color={color} />;
})}
@ -41,11 +50,17 @@ export class ColorPicker extends React.Component<PickerProps, {}> {
public render() {
const cb = this.props.onChange || function () { };
return <Popover
return <Popover className="color-picker"
position={this.props.position || Position.BOTTOM}
popoverClassName="colorpicker-menu gray">
<Saucer color={this.props.current} />
<ColorPickerCluster onChange={cb} current={this.props.current} />
{this.props.saucerIcon
? <i className={`icon-saucer fa ${this.props.saucerIcon} ${
this.props.current}`} />
: <Saucer color={this.props.current} />}
<ColorPickerCluster
onChange={cb}
current={this.props.current}
saucerIcon={this.props.saucerIcon} />
</Popover>;
}
}