Applications Page Styling

This commit is contained in:
Abhinav Jha 2019-11-21 10:52:49 +00:00
parent d089341df1
commit 2fa79e403d
24 changed files with 703 additions and 200 deletions

View File

@ -14,6 +14,7 @@
"@blueprintjs/timezone": "^3.6.0",
"@craco/craco": "^5.6.1",
"@sentry/browser": "^5.6.3",
"@types/faker": "^4.1.7",
"@types/fontfaceobserver": "^0.0.6",
"@types/lodash": "^4.14.120",
"@types/moment-timezone": "^0.5.10",
@ -34,6 +35,7 @@
"@uppy/webcam": "^1.3.1",
"axios": "^0.18.0",
"eslint": "^6.4.0",
"faker": "^4.1.0",
"flow-bin": "^0.91.0",
"fontfaceobserver": "^2.1.0",
"fuse.js": "^3.4.5",

View File

@ -0,0 +1,13 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon/Outline/more-vertical">
<path id="Mask" fill-rule="evenodd" clip-rule="evenodd" d="M12 7C13.104 7 14 6.104 14 5C14 3.896 13.104 3 12 3C10.896 3 10 3.896 10 5C10 6.104 10.896 7 12 7ZM12 10C10.896 10 10 10.896 10 12C10 13.104 10.896 14 12 14C13.104 14 14 13.104 14 12C14 10.896 13.104 10 12 10ZM10 19C10 17.896 10.896 17 12 17C13.104 17 14 17.896 14 19C14 20.104 13.104 21 12 21C10.896 21 10 20.104 10 19Z" fill="#A3B3BF"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="10" y="3" width="4" height="18">
<path id="Mask_2" fill-rule="evenodd" clip-rule="evenodd" d="M12 7C13.104 7 14 6.104 14 5C14 3.896 13.104 3 12 3C10.896 3 10 3.896 10 5C10 6.104 10.896 7 12 7ZM12 10C10.896 10 10 10.896 10 12C10 13.104 10.896 14 12 14C13.104 14 14 13.104 14 12C14 10.896 13.104 10 12 10ZM10 19C10 17.896 10.896 17 12 17C13.104 17 14 17.896 14 19C14 20.104 13.104 21 12 21C10.896 21 10 20.104 10 19Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<g id="&#240;&#159;&#142;&#168; Color">
<rect id="Base" width="24" height="24" fill="#A3B3BF"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,5 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="10" fill="#21282C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 10.9513C9.47555 10.9513 9.04876 10.5245 9.04876 10C9.04876 9.47556 9.47555 9.04876 10 9.04876C10.5245 9.04876 10.9513 9.47556 10.9513 10C10.9513 10.5245 10.5245 10.9513 10 10.9513ZM10 7.78042C8.77606 7.78042 7.78041 8.77607 7.78041 10C7.78041 11.224 8.77606 12.2196 10 12.2196C11.224 12.2196 12.2196 11.224 12.2196 10C12.2196 8.77607 11.224 7.78042 10 7.78042ZM10.1393 13.1694C7.4086 13.2328 5.62721 10.8971 5.03616 9.99723C5.68682 8.97938 7.32552 6.89549 9.86094 6.83081C12.5809 6.76168 14.3724 9.10304 14.9635 10.0029C14.3135 11.0208 12.6741 13.1047 10.1393 13.1694ZM16.2578 9.68458C15.8532 8.97938 13.6184 5.44451 9.8286 5.5631C6.3229 5.65188 4.28403 8.7403 3.74245 9.68458C3.6302 9.8799 3.6302 10.1203 3.74245 10.3156C4.14134 11.0113 6.29753 14.439 10.0157 14.439C10.0677 14.439 10.1197 14.4383 10.1717 14.4371C13.6768 14.3476 15.7163 11.2599 16.2578 10.3156C16.3694 10.1203 16.3694 9.8799 16.2578 9.68458Z" fill="#BCCCD9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 10.9513C9.47555 10.9513 9.04876 10.5245 9.04876 10C9.04876 9.47556 9.47555 9.04876 10 9.04876C10.5245 9.04876 10.9513 9.47556 10.9513 10C10.9513 10.5245 10.5245 10.9513 10 10.9513ZM10 7.78042C8.77606 7.78042 7.78041 8.77607 7.78041 10C7.78041 11.224 8.77606 12.2196 10 12.2196C11.224 12.2196 12.2196 11.224 12.2196 10C12.2196 8.77607 11.224 7.78042 10 7.78042ZM10.1393 13.1694C7.4086 13.2328 5.62721 10.8971 5.03616 9.99723C5.68682 8.97938 7.32552 6.89549 9.86094 6.83081C12.5809 6.76168 14.3724 9.10304 14.9635 10.0029C14.3135 11.0208 12.6741 13.1047 10.1393 13.1694ZM16.2578 9.68458C15.8532 8.97938 13.6184 5.44451 9.8286 5.5631C6.3229 5.65188 4.28403 8.7403 3.74245 9.68458C3.6302 9.8799 3.6302 10.1203 3.74245 10.3156C4.14134 11.0113 6.29753 14.439 10.0157 14.439C10.0677 14.439 10.1197 14.4383 10.1717 14.4371C13.6768 14.3476 15.7163 11.2599 16.2578 10.3156C16.3694 10.1203 16.3694 9.8799 16.2578 9.68458Z" fill="white"/>
<svg width="10" height="12" viewBox="0 0 10 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 6.001C10 6.3 9.695 6.515 9.695 6.515L1.134 11.818C0.51 12.227 0 11.924 0 11.149V0.852C0 0.0749997 0.51 -0.226 1.135 0.182L9.696 5.487C9.695 5.487 10 5.702 10 6.001Z" fill="#A3B3BF"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 299 B

View File

@ -0,0 +1,66 @@
import React, { ReactNode, useState, useEffect } from "react";
import { connect } from "react-redux";
import { submit } from "redux-form";
import { Dialog, Classes, Button, Intent, Callout } from "@blueprintjs/core";
type FormDialogComponentProps = {
isOpen: boolean;
title: string;
onClose: () => void;
form: ReactNode;
isSubmitting: boolean;
submitIntent: string;
formName: string;
error?: string;
dispatch: Function;
};
export const FormDialogComponent = (props: FormDialogComponentProps) => {
const submitHandler = () => props.dispatch(submit(props.formName));
const [isPristine, makePristine] = useState(true);
const clearErrors = () => {
makePristine(true);
};
useEffect(() => {
if (props.error) {
makePristine(false);
}
}, [props.error]);
return (
<Dialog
isOpen={props.isOpen}
canOutsideClickClose={false}
canEscapeKeyClose={false}
title={props.title}
onClose={props.onClose}
onOpening={clearErrors}
>
{props.error && !isPristine && (
<Callout intent="danger">{props.error}</Callout>
)}
<div className={Classes.DIALOG_BODY}>{props.form}</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
text="Cancel"
type="button"
intent={Intent.NONE}
onClick={props.onClose}
/>
<Button
text="Submit"
type="submit"
onClick={submitHandler}
intent={props.submitIntent as Intent}
loading={props.isSubmitting && !props.error}
/>
</div>
</div>
</Dialog>
);
};
export default connect()(FormDialogComponent);

View File

@ -0,0 +1,84 @@
import React, { ReactNode } from "react";
import styled from "styled-components";
import { ItemRenderer, Select } from "@blueprintjs/select";
import { Button, MenuItem } from "@blueprintjs/core";
import { DropdownOption } from "widgets/DropdownWidget";
import { ControlIconName, ControlIcons } from "icons/ControlIcons";
import { noop } from "utils/AppsmithUtils";
import { Intent } from "constants/DefaultTheme";
export type ContextDropdownOption = DropdownOption & {
onSelect: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
intent?: Intent;
};
const Dropdown = Select.ofType<ContextDropdownOption>();
const StyledMenuItem = styled(MenuItem)`
&&&&.bp3-menu-item:hover {
background: ${props => props.theme.colors.primary};
color: ${props => props.theme.colors.textOnDarkBG};
}
&&&.bp3-menu-item.bp3-intent-danger:hover {
background: ${props => props.theme.colors.error};
}
`;
type ContextDropdownProps = {
options: ContextDropdownOption[];
className: string;
toggle: {
type: "icon" | "button";
icon?: ControlIconName;
text?: string;
placeholder?: string;
};
};
export const ContextDropdown = (props: ContextDropdownProps) => {
let trigger: ReactNode;
if (props.toggle.type === "icon" && props.toggle.icon)
trigger = ControlIcons[props.toggle.icon]();
if (props.toggle.type === "button" && props.toggle.text)
trigger = <Button text={props.toggle.text} />;
const renderer: ItemRenderer<ContextDropdownOption> = (
option: ContextDropdownOption,
) => {
return (
<StyledMenuItem
key={option.value}
onClick={option.onSelect}
shouldDismissPopover={true}
text={option.label || option.value}
intent={option.intent as Intent}
popoverProps={{
minimal: true,
hoverCloseDelay: 0,
hoverOpenDelay: 0,
modifiers: {
arrow: {
enabled: false,
},
offset: {
enabled: true,
offset: "-16px 0",
},
},
}}
/>
);
};
return (
<Dropdown
items={props.options}
itemRenderer={renderer}
onItemSelect={noop}
filterable={false}
className={props.className}
>
{trigger}
</Dropdown>
);
};
export default ContextDropdown;

View File

@ -1,4 +1,4 @@
import React, { Component } from "react";
import React, { Component, ReactNode } from "react";
import styled from "styled-components";
import { MenuItem, Menu, ControlGroup, InputGroup } from "@blueprintjs/core";
import { BaseButton } from "../designSystems/blueprint/ButtonComponent";
@ -128,11 +128,13 @@ class DropdownComponent extends Component<DropdownComponentProps> {
activeItem={this.props.selected}
noResults={<MenuItem disabled={true} text="No results." />}
>
<BaseButton
styleName="secondary"
text={this.getSelectedDisplayText()}
rightIcon="chevron-down"
/>
{this.props.toggle || (
<BaseButton
styleName="secondary"
text={this.getSelectedDisplayText()}
rightIcon="chevron-down"
/>
)}
</StyledDropdown>
);
}
@ -150,6 +152,7 @@ export interface DropdownComponentProps {
displayText: string;
addItemHandler: (name: string) => void;
};
toggle?: ReactNode;
}
export default DropdownComponent;

View File

@ -4,7 +4,7 @@ export const Colors: Record<string, string> = {
POLAR: "#E9FAF3",
GEYSER: "#D3DEE3",
GEYSER_LIGHT: "#D0D7DD",
ATHENS_GRAY: "#FAFBFC",
ATHENS_GRAY: "#EBEFF2",
CONCRETE: "#F3F3F3",
MYSTIC: "#E1E8ED",
AQUA_HAZE: "#EEF2F5",

View File

@ -13,6 +13,8 @@ const {
ThemeProvider,
} = styledComponents as styledComponents.ThemedStyledComponentsModule<Theme>;
export type Intent = "primary" | "danger" | "warning" | "none";
export type ThemeBorder = {
thickness: number;
style: "dashed" | "solid";
@ -49,6 +51,10 @@ export type Theme = {
card: {
minWidth: number;
minHeight: number;
titleHeight: number;
divider: ThemeBorder;
hoverBG: Color;
hoverBGOpacity: number;
};
shadows: string[];
widgets: {
@ -112,6 +118,8 @@ export const theme: Theme = {
containerBorder: Colors.FRENCH_PASS,
menuButtonBGInactive: Colors.JUNGLE_MIST,
menuIconColorInactive: Colors.OXFORD_BLUE,
bodyBG: Colors.ATHENS_GRAY,
builderBodyBG: Colors.WHITE,
},
lineHeights: [0, 14, 18, 22, 24, 28, 36, 48, 64, 80],
fonts: [
@ -147,8 +155,16 @@ export const theme: Theme = {
navItemHeight: 42,
},
card: {
minWidth: 300,
minHeight: 300,
minWidth: 282,
minHeight: 220,
titleHeight: 48,
divider: {
thickness: 1,
style: "solid",
color: Colors.GEYSER_LIGHT,
},
hoverBG: Colors.BLACK,
hoverBGOpacity: 0.5,
},
shadows: ["0px 2px 4px rgba(67, 70, 74, 0.14)"],
widgets: {

View File

@ -1 +1,2 @@
export const API_EDITOR_FORM_NAME = "ApiEditorForm";
export const CREATE_APPLICATION_FORM_NAME = "CreateApplicationForm";

View File

@ -1,2 +1,5 @@
export const ERROR_MESSAGE_SELECT_ACTION = "Please select an action";
export const ERROR_MESSAGE_SELECT_ACTION_TYPE = "Please select an action type";
export const ERROR_MESSAGE_CREATE_APPLICATION =
"We could not create the Application";

View File

@ -3,7 +3,8 @@ import { IconProps, IconWrapper } from "../constants/IconConstants";
import { ReactComponent as DeleteIcon } from "../assets/icons/control/delete.svg";
import { ReactComponent as MoveIcon } from "../assets/icons/control/move.svg";
import { ReactComponent as EditIcon } from "../assets/icons/control/edit.svg";
import { ReactComponent as ViewIcon } from "../assets/icons/control/view.svg";
import { ReactComponent as ViewIcon } from "assets/icons/control/view.svg";
import { ReactComponent as MoreVerticalIcon } from "assets/icons/control/more-vertical.svg";
/* eslint-disable react/display-name */
@ -30,4 +31,11 @@ export const ControlIcons: {
<ViewIcon />
</IconWrapper>
),
MORE_VERTICAL_CONTROL: (props: IconProps) => (
<IconWrapper {...props}>
<MoreVerticalIcon />
</IconWrapper>
),
};
export type ControlIconName = keyof typeof ControlIcons;

View File

@ -17,3 +17,12 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
div.bp3-select-popover .bp3-popover-content {
padding: 0;
}
div.bp3-popover-arrow {
display:none;
}

View File

@ -21,6 +21,7 @@ import HTML5Backend from "react-dnd-html5-backend";
import { appInitializer } from "./utils/AppsmithUtils";
import ProtectedRoute from "./pages/common/ProtectedRoute";
import { composeWithDevTools } from "redux-devtools-extension/logOnlyInProduction";
import {
BASE_URL,
BUILDER_URL,
@ -31,12 +32,14 @@ import {
import Applications from "./pages/Applications";
appInitializer();
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
appReducer,
composeWithDevTools(applyMiddleware(sagaMiddleware)),
);
sagaMiddleware.run(rootSaga);
ReactDOM.render(
<DndProvider backend={HTML5Backend}>
<Provider store={store}>

View File

@ -0,0 +1,13 @@
import { ApplicationPayload } from "constants/ReduxActionConstants";
import faker from "faker";
export const getApplicationPayload = (): ApplicationPayload => ({
id: faker.random.uuid(),
name: faker.random.word(),
organizationId: faker.random.uuid(),
pageCount: faker.random.number(),
});
export const getApplicationPayloads = (count: number): ApplicationPayload[] => {
return [...Array(count).keys()].map(getApplicationPayload);
};

View File

@ -0,0 +1,186 @@
import React from "react";
import styled from "styled-components";
import { Card, Tooltip, Classes } from "@blueprintjs/core";
import { ApplicationPayload } from "constants/ReduxActionConstants";
import { BaseButton } from "components/designSystems/blueprint/ButtonComponent";
import {
theme,
getBorderCSSShorthand,
getColorWithOpacity,
} from "constants/DefaultTheme";
import { ControlIcons } from "icons/ControlIcons";
import ContextDropdown, {
ContextDropdownOption,
} from "components/editorComponents/ContextDropdown";
const Wrapper = styled(Card)`
display: flex;
flex-direction: column;
justify-content: center;
width: ${props => props.theme.card.minWidth}px;
height: ${props => props.theme.card.minHeight}px;
position: relative;
border-radius: ${props => props.theme.radii[1]}px;
margin: ${props => props.theme.spaces[5]}px
${props => props.theme.spaces[5]}px;
&:hover {
&:after {
left: 0;
top: 0;
content: "";
position: absolute;
height: 100%;
width: 100%;
}
& .control {
display: block;
z-index: 1;
}
& div.image-container {
background: ${props =>
getColorWithOpacity(
props.theme.card.hoverBG,
props.theme.card.hoverBGOpacity,
)};
}
}
`;
const ApplicationTitle = styled.div`
font-size: ${props => props.theme.fontSizes[2]}px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: absolute;
bottom: 0;
left: 0;
height: ${props => props.theme.card.titleHeight}px;
padding: ${props => props.theme.spaces[6]}px;
width: 100%;
border-top: ${props => getBorderCSSShorthand(props.theme.card.divider)};
font-weight: ${props => props.theme.fontWeights[2]};
font-size: ${props => props.theme.fontSizes[4]}px;
& {
span {
display: inline-block;
}
.control {
display: none;
z-index: 1;
position: absolute;
right: ${props => props.theme.spaces[5] * 3}px;
top: ${props => props.theme.spaces[5]}px;
}
.more {
z-index: 1;
position: absolute;
right: ${props => props.theme.spaces[2]}px;
top: ${props => props.theme.spaces[4]}px;
cursor: pointer;
}
}
`;
const ApplicationImage = styled.div`
position: absolute;
left: 0;
top: 0;
height: calc(100% - ${props => props.theme.card.titleHeight}px);
width: 100%;
display: flex;
justify-content: center;
align-items: center;
& {
.control {
display: none;
}
}
`;
const Control = styled.button<{ fixed?: boolean }>`
outline: none;
background: none;
border: none;
cursor: pointer;
`;
const APPLICATION_CONTROL_FONTSIZE_INDEX = 6;
const viewControlIcon = ControlIcons.VIEW_CONTROL({
width: theme.fontSizes[APPLICATION_CONTROL_FONTSIZE_INDEX],
height: theme.fontSizes[APPLICATION_CONTROL_FONTSIZE_INDEX],
});
type ApplicationCardProps = {
application: ApplicationPayload;
loading: boolean;
duplicate: (applicationId: string) => void;
share: (applicationId: string) => void;
delete: (applicationId: string) => void;
};
export const ApplicationCard = (props: ApplicationCardProps) => {
const duplicateApp = () => {
props.duplicate(props.application.id);
};
const shareApp = () => {
props.share(props.application.id);
};
const deleteApp = () => {
props.delete(props.application.id);
};
const moreActionItems: ContextDropdownOption[] = [
{
id: "duplicate",
value: "duplicate",
onSelect: duplicateApp,
label: "Duplicate",
},
{
id: "share",
value: "share",
onSelect: shareApp,
label: "Share",
},
{
id: "delete",
value: "delete",
onSelect: deleteApp,
label: "Delete",
intent: "danger",
},
];
return (
<Wrapper key={props.application.id}>
<ApplicationTitle
className={props.loading ? Classes.SKELETON : undefined}
>
<span>{props.application.name}</span>
<Control className="control">
<Tooltip content="View Application" hoverOpenDelay={500}>
{viewControlIcon}
</Tooltip>
</Control>
<ContextDropdown
options={moreActionItems}
toggle={{ type: "icon", icon: "MORE_VERTICAL_CONTROL" }}
className="more"
/>
</ApplicationTitle>
<ApplicationImage className="image-container">
<Control className="control">
<Tooltip content="Edit Application" hoverOpenDelay={500}>
<BaseButton
icon="edit"
text="Edit"
styleName="primary"
filled={true}
/>
</Tooltip>
</Control>
</ApplicationImage>
</Wrapper>
);
};
export default ApplicationCard;

View File

@ -1,38 +1,32 @@
import React, { useRef, MutableRefObject } from "react";
import { BaseButton } from "../../components/designSystems/blueprint/ButtonComponent";
import { ControlGroup, Classes } from "@blueprintjs/core";
import React from "react";
import { Form, reduxForm, InjectedFormProps } from "redux-form";
import { CREATE_APPLICATION_FORM_NAME } from "constants/forms";
import {
CreateApplicationFormValues,
createApplicationFormSubmitHandler,
} from "utils/formhelpers";
import TextField from "components/editorComponents/fields/TextField";
import { required } from "utils/validation/common";
import { FormGroup } from "@blueprintjs/core";
type CreateApplicationFormProps = {
onCreate: (name: string) => void;
creating: boolean;
error?: string;
};
export const CreateApplicationForm = (props: CreateApplicationFormProps) => {
const inputRef: MutableRefObject<HTMLInputElement | null> = useRef(null);
const handleCreate = () => {
if (inputRef && inputRef.current) {
props.onCreate(inputRef.current.value);
} else {
//TODO (abhinav): Add validation code.
}
};
export const CreateApplicationForm = (
props: InjectedFormProps<CreateApplicationFormValues>,
) => {
const { error, handleSubmit } = props;
return (
<ControlGroup fill vertical>
<input
type="text"
className={Classes.INPUT}
ref={inputRef}
placeholder="Application Name"
/>
<BaseButton
text="Create Application"
onClick={handleCreate}
styleName="secondary"
loading={props.creating}
></BaseButton>
</ControlGroup>
<Form onSubmit={handleSubmit(createApplicationFormSubmitHandler)}>
<FormGroup intent={error ? "danger" : "none"} helperText={error}>
<TextField
name="applicationName"
placeholderMessage="Name"
validate={required}
/>
</FormGroup>
</Form>
);
};
export default CreateApplicationForm;
export default reduxForm<CreateApplicationFormValues>({
form: CREATE_APPLICATION_FORM_NAME,
onSubmit: createApplicationFormSubmitHandler,
})(CreateApplicationForm);

View File

@ -2,172 +2,103 @@ import React, { Component } from "react";
import styled from "styled-components";
import { connect } from "react-redux";
import { withRouter } from "react-router";
import {
getApplicationBuilderURL,
getApplicationViewerURL,
} from "../../constants/routes";
import { AppState } from "../../reducers";
import {
getApplicationList,
getIsFetchingApplications,
getIsCreatingApplication,
getCreateApplicationError,
} from "../../selectors/applicationSelectors";
import {
ReduxActionTypes,
ApplicationPayload,
} from "../../constants/ReduxActionConstants";
import { Card, Spinner, Tooltip } from "@blueprintjs/core";
import { ControlIcons } from "../../icons/ControlIcons";
import { theme } from "../../constants/DefaultTheme";
import { Divider } from "@blueprintjs/core";
import ApplicationsHeader from "./ApplicationsHeader";
import SubHeader from "pages/common/SubHeader";
import { getApplicationPayloads } from "mockComponentProps/ApplicationPayloads";
import ApplicationCard from "./ApplicationCard";
import CreateApplicationForm from "./CreateApplicationForm";
import { CREATE_APPLICATION_FORM_NAME } from "constants/forms";
import { noop } from "utils/AppsmithUtils";
const APPLICATION_CONTROL_FONTSIZE_INDEX = 7;
const ApplicationsBody = styled.section`
const ApplicationsPageWrapper = styled.section`
width: 100vw;
`;
const SectionDivider = styled(Divider)`
margin: ${props => props.theme.spaces[11]}px auto;
width: 100%;
`;
const ApplicationsBody = styled.div`
width: 1224px;
min-height: calc(100vh - ${props => props.theme.headerHeight});
display: flex;
justify-content: center;
align-items: center;
background: white;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
margin: ${props => props.theme.spaces[12]}px auto;
background: ${props => props.theme.colors.bodyBG};
`;
const ApplicationCardsWrapper = styled.div`
display: flex;
flex-flow: row wrap;
justify-content: center;
align-items: space-around;
width: 80%;
justify-content: flex-start;
align-items: space-evenly;
`;
const ApplicationCard = styled(Card)`
display: flex;
flex-direction: column;
justify-content: center;
min-width: ${props => props.theme.card.minWidth}px;
min-height: ${props => props.theme.card.minHeight}px;
position: relative;
margin-bottom: ${props => props.theme.spaces[2]}px;
margin-right: ${props => props.theme.spaces[2]}px;
&:hover {
& div.controls {
display: flex;
}
}
`;
const ApplicationTitle = styled.h1`
font-size: ${props => props.theme.fontSizes[7]}px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
`;
const ApplicationControls = styled.div`
display: none;
flex-flow: row nowrap;
justify-content: space-around;
`;
const Control = styled.button<{ fixed?: boolean }>`
outline: none;
background: none;
border: none;
cursor: pointer;
position: ${props => (props.fixed ? "absolute" : "auto")};
right: ${props => props.theme.spaces[2]}px;
top: ${props => props.theme.spaces[2]}px;
`;
const editControlIcon = ControlIcons.EDIT_CONTROL({
width: theme.fontSizes[APPLICATION_CONTROL_FONTSIZE_INDEX],
height: theme.fontSizes[APPLICATION_CONTROL_FONTSIZE_INDEX],
});
const deleteControlIcon = ControlIcons.DELETE_CONTROL({
width: theme.fontSizes[APPLICATION_CONTROL_FONTSIZE_INDEX],
height: theme.fontSizes[APPLICATION_CONTROL_FONTSIZE_INDEX],
background: theme.colors.error,
});
const viewControlIcon = ControlIcons.VIEW_CONTROL({
width: theme.fontSizes[APPLICATION_CONTROL_FONTSIZE_INDEX],
height: theme.fontSizes[APPLICATION_CONTROL_FONTSIZE_INDEX],
});
type ApplicationProps = {
applicationList: ApplicationPayload[];
fetchApplications: () => void;
createApplication: (appName: string) => void;
isCreatingApplication: boolean;
isFetchingApplications: boolean;
createApplicationError?: string;
history: any;
};
class Applications extends Component<ApplicationProps> {
handleEditApplication = (applicationId: string) => () => {
this.props.history.push(getApplicationBuilderURL(applicationId));
};
handleViewApplication = (applicationId: string, pageId?: string) => () => {
this.props.history.push(getApplicationViewerURL(applicationId, pageId));
};
renderApplicationCard = (application: ApplicationPayload) => {
return (
<ApplicationCard interactive key={application.id}>
<ApplicationTitle>{application.name}</ApplicationTitle>
<ApplicationControls className="controls">
<Control fixed>
<Tooltip content="Delete Application" hoverOpenDelay={500}>
{deleteControlIcon}
</Tooltip>
</Control>
<Control
onClick={this.handleViewApplication(
application.id,
application.defaultPageId,
)}
>
<Tooltip content="View Application" hoverOpenDelay={500}>
{viewControlIcon}
</Tooltip>
</Control>
<Control onClick={this.handleEditApplication(application.id)}>
<Tooltip content="Edit Application" hoverOpenDelay={500}>
{editControlIcon}
</Tooltip>
</Control>
</ApplicationControls>
</ApplicationCard>
);
};
componentDidMount() {
this.props.fetchApplications();
}
public render() {
const applicationList = this.props.isFetchingApplications
? getApplicationPayloads(4)
: this.props.applicationList;
return (
<div>
<ApplicationsHeader
add={{
form: (
<CreateApplicationForm
onCreate={this.props.createApplication}
creating={this.props.isCreatingApplication}
/>
),
title: "Create Application",
}}
/>
<ApplicationsPageWrapper>
<ApplicationsHeader />
<ApplicationsBody>
{this.props.applicationList ? (
<ApplicationCardsWrapper>
{this.props.applicationList.map(this.renderApplicationCard)}
</ApplicationCardsWrapper>
) : (
<Spinner />
)}
<SubHeader
add={{
form: <CreateApplicationForm />,
title: "Create New App",
formName: CREATE_APPLICATION_FORM_NAME,
formSubmitIntent: "primary",
isAdding:
this.props.isCreatingApplication ||
!!this.props.createApplicationError,
errorAdding: this.props.createApplicationError,
}}
search={{
placeholder: "Search",
}}
/>
<SectionDivider />
<ApplicationCardsWrapper>
{applicationList.map((application: ApplicationPayload) => (
<ApplicationCard
key={application.id}
loading={this.props.isFetchingApplications}
application={application}
share={noop}
duplicate={noop}
delete={noop}
/>
))}
</ApplicationCardsWrapper>
</ApplicationsBody>
</div>
</ApplicationsPageWrapper>
);
}
}
@ -176,6 +107,7 @@ const mapStateToProps = (state: AppState) => ({
applicationList: getApplicationList(state),
isFetchingApplications: getIsFetchingApplications(state),
isCreatingApplication: getIsCreatingApplication(state),
createApplicationError: getCreateApplicationError(state),
});
const mapDispatchToProps = (dispatch: any) => ({
@ -192,8 +124,5 @@ const mapDispatchToProps = (dispatch: any) => ({
});
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps,
)(Applications),
connect(mapStateToProps, mapDispatchToProps)(Applications),
);

View File

@ -0,0 +1,92 @@
import React, { ReactNode, useState, useEffect } from "react";
import FormDialogComponent from "components/designSystems/blueprint/FormDialogComponent";
import {
ControlGroup,
InputGroup,
Button,
IIconProps,
} from "@blueprintjs/core";
import styled from "styled-components";
const SubHeaderWrapper = styled.div`
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 ${props => props.theme.spaces[5]}px;
`;
const StyledAddButton = styled(Button)<IIconProps>`
&&& {
background: ${props => props.theme.colors.primary};
span {
color: white;
}
outline: none;
}
`;
const SearchContainer = styled.div``;
type ApplicationsSubHeaderProps = {
add?: {
form: ReactNode;
title: string;
formName: string;
isAdding: boolean;
formSubmitIntent: string;
errorAdding?: string;
};
search?: {
placeholder: string;
};
};
export const ApplicationsSubHeader = (props: ApplicationsSubHeaderProps) => {
const [isOpen, setIsOpen] = useState(false);
const openForm = () => {
setIsOpen(true);
};
const closeForm = () => {
setIsOpen(false);
};
useEffect(() => {
if (props.add && !props.add.isAdding) {
setIsOpen(false);
}
}, [props.add]);
return (
<SubHeaderWrapper>
<SearchContainer>
{props.search && (
<ControlGroup>
<InputGroup
placeholder={props.search.placeholder}
leftIcon="search"
/>
</ControlGroup>
)}
</SearchContainer>
{props.add && (
<StyledAddButton
text={props.add.title}
icon="plus"
title={props.add.title}
onClick={openForm}
minimal
/>
)}
{props.add && (
<FormDialogComponent
isOpen={isOpen}
formName={props.add.formName}
form={props.add.form}
error={props.add.errorAdding}
title={props.add.title}
isSubmitting={props.add.isAdding}
onClose={closeForm}
submitIntent={props.add.formSubmitIntent}
/>
)}
</SubHeaderWrapper>
);
};
export default ApplicationsSubHeader;

View File

@ -5,6 +5,7 @@ import {
ReduxActionErrorTypes,
ApplicationPayload,
} from "../../constants/ReduxActionConstants";
import { ERROR_MESSAGE_CREATE_APPLICATION } from "constants/messages";
const initialState: ApplicationsReduxState = {
isFetchingApplications: false,
@ -19,10 +20,18 @@ const applicationsReducer = createReducer(initialState, {
[ReduxActionTypes.FETCH_APPLICATION_LIST_SUCCESS]: (
state: ApplicationsReduxState,
action: ReduxAction<{ applicationList: ApplicationPayload[] }>,
) => ({ ...state, applicationList: action.payload }),
) => ({
...state,
applicationList: action.payload,
isFetchingApplications: false,
}),
[ReduxActionTypes.CREATE_APPLICATION_INIT]: (
state: ApplicationsReduxState,
) => ({ ...state, creatingApplication: true }),
) => ({
...state,
creatingApplication: true,
createApplicationError: undefined,
}),
[ReduxActionTypes.CREATE_APPLICATION_SUCCESS]: (
state: ApplicationsReduxState,
action: ReduxAction<ApplicationPayload>,
@ -39,6 +48,7 @@ const applicationsReducer = createReducer(initialState, {
return {
...state,
creatingApplication: false,
createApplicationError: ERROR_MESSAGE_CREATE_APPLICATION,
};
},
});
@ -47,6 +57,7 @@ export interface ApplicationsReduxState {
applicationList: ApplicationPayload[];
isFetchingApplications: boolean;
creatingApplication: boolean;
createApplicationError?: string;
}
export default applicationsReducer;

View File

@ -12,9 +12,10 @@ import ApplicationApi, {
CreateApplicationResponse,
ApplicationPagePayload,
} from "../api/ApplicationApi";
import { call, put, takeLatest, all } from "redux-saga/effects";
import { call, put, takeLatest, all, select } from "redux-saga/effects";
import { validateResponse } from "./ErrorSagas";
import { getApplicationList } from "selectors/applicationSelectors";
export function* publishApplicationSaga(
requestAction: ReduxAction<PublishApplicationRequest>,
@ -86,32 +87,62 @@ export function* fetchApplicationListSaga() {
}
export function* createApplicationSaga(
request: ReduxAction<CreateApplicationRequest>,
action: ReduxAction<{
applicationName: string;
resolve: any;
reject: any;
}>,
) {
try {
const response: CreateApplicationResponse = yield call(
ApplicationApi.createApplication,
request.payload,
const { applicationName, resolve, reject } = action.payload;
const applicationList: ApplicationPayload[] = yield select(
getApplicationList,
);
const isValidResponse = yield validateResponse(response);
if (isValidResponse) {
const application: ApplicationPayload = {
id: response.data.id,
name: response.data.name,
organizationId: response.data.organizationId,
pageCount: response.data.pages ? response.data.pages.length : 0,
defaultPageId: getDefaultPageId(response.data.pages),
};
yield put({
type: ReduxActionTypes.CREATE_APPLICATION_SUCCESS,
payload: application,
const existingApplication = applicationList.find(application => {
return application.name === applicationName;
});
if (existingApplication) {
yield call(reject, {
_error: "An application with this name already exists",
});
yield put({
type: ReduxActionErrorTypes.CREATE_APPLICATION_ERROR,
payload: {
error: "Could not create application",
show: false,
},
});
} else {
const request: CreateApplicationRequest = { name: applicationName };
const response: CreateApplicationResponse = yield call(
ApplicationApi.createApplication,
request,
);
const isValidResponse = yield validateResponse(response);
if (isValidResponse) {
const application: ApplicationPayload = {
id: response.data.id,
name: response.data.name,
organizationId: response.data.organizationId,
pageCount: response.data.pages ? response.data.pages.length : 0,
defaultPageId: getDefaultPageId(response.data.pages),
};
yield put({
type: ReduxActionTypes.CREATE_APPLICATION_SUCCESS,
payload: application,
});
yield call(resolve);
} else {
yield call(reject, { _error: "Could not create application" });
}
}
} catch (error) {
yield put({
type: ReduxActionErrorTypes.CREATE_APPLICATION_ERROR,
payload: {
error,
show: false,
},
});
}

View File

@ -48,7 +48,7 @@ ActionErrorDisplayMap = {
};
export function* errorSaga(
errorAction: ReduxAction<{ error: ErrorPayloadType }>,
errorAction: ReduxAction<{ error: ErrorPayloadType; show?: boolean }>,
) {
// Just a pass through for now.
// Add procedures to customize errors here
@ -56,10 +56,10 @@ export function* errorSaga(
// Show a toast when the error occurs
const {
type,
payload: { error },
payload: { error, show = true },
} = errorAction;
const message = ActionErrorDisplayMap[type](error);
AppToaster.show({ message, intent: Intent.DANGER });
if (show) AppToaster.show({ message, intent: Intent.DANGER });
yield put({
type: ReduxActionTypes.REPORT_ERROR,
payload: {

View File

@ -22,3 +22,9 @@ export const getIsCreatingApplication = createSelector(
(applications: ApplicationsReduxState): boolean =>
applications.creatingApplication,
);
export const getCreateApplicationError = createSelector(
getApplicationsState,
(applications: ApplicationsReduxState): string | undefined =>
applications.createApplicationError,
);

View File

@ -0,0 +1,25 @@
import { SubmissionError } from "redux-form";
import { ReduxActionTypes } from "constants/ReduxActionConstants";
export type CreateApplicationFormValues = {
applicationName: string;
};
export const createApplicationFormSubmitHandler = (
values: CreateApplicationFormValues,
dispatch: any,
): Promise<any> => {
const { applicationName } = values;
return new Promise((resolve, reject) => {
dispatch({
type: ReduxActionTypes.CREATE_APPLICATION_INIT,
payload: {
resolve,
reject,
applicationName,
},
});
}).catch(error => {
throw new SubmissionError(error);
});
};

View File

@ -1555,6 +1555,11 @@
resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
"@types/faker@^4.1.7":
version "4.1.7"
resolved "https://registry.yarnpkg.com/@types/faker/-/faker-4.1.7.tgz#27e8dff9bc0d11c798e7c4a414c4dbd3755a9a34"
integrity sha512-tq8puryvH3X1Stlg6mma27/BI2BOwcQOvg/uU7LH7dAsCnyfVEtUXTbcksMZgOA/BZur8rkn9C0CFEgpbxfTdA==
"@types/fontfaceobserver@^0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@types/fontfaceobserver/-/fontfaceobserver-0.0.6.tgz#14a4a02b77e66e6a1070622981d431c885a174ed"
@ -5032,6 +5037,11 @@ extsprintf@^1.2.0:
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
faker@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/faker/-/faker-4.1.0.tgz#1e45bbbecc6774b3c195fad2835109c6d748cc3f"
integrity sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8=
fast-deep-equal@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"