Task/5463 Navigation Bar menu Dropdown (#5575)

* Feature/ Navigation Bar menu

* Cypress test fixes and minor style changes

* Changed some more integration test files and fixed some styling post feedback

* Updated failing tests

* Minor Cypress test change

* requested design changes and Analytics

* resolving conflicts

* minor style fix

* current deploy verbage change

Co-authored-by: Rishabh Saxena  <rishabh.robben@gmail.com>

* addressing review comments

* Updating discord link with permanent invite

Co-authored-by: Rishabh Saxena  <rishabh.robben@gmail.com>
Co-authored-by: Rahul R <rahulramesha@Rahuls-MacBook-Pro.local>
This commit is contained in:
rahulramesha 2021-07-15 16:26:18 +05:30 committed by GitHub
parent 0bd58971e3
commit 3042adbdea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1100 additions and 253 deletions

View File

@ -13,6 +13,7 @@ describe("Update Application", function() {
it("Open the application menu and update name and then check whether update is reflected in the application card", function() {
cy.get(commonlocators.homeIcon).click({ force: true });
appname = localStorage.getItem("AppName");
cy.get(homePage.searchInput).clear();
cy.get(homePage.searchInput).type(appname);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(2000);
@ -53,6 +54,7 @@ describe("Update Application", function() {
it("Check for errors in updating application name", function() {
cy.get(commonlocators.homeIcon).click({ force: true });
cy.get(homePage.searchInput).clear();
cy.get(homePage.searchInput).type(appname);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(2000);
@ -65,11 +67,15 @@ describe("Update Application", function() {
cy.get("#loading").should("not.exist");
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(2000);
cy.AppSetupForRename();
cy.get(homePage.applicationName).type(" ");
cy.get(homePage.toastMessage).should(
"contain",
"Application name can't be empty",
);
cy.AppSetupForRename();
cy.get(homePage.applicationName).type(" " + "{enter}");
cy.wait("@updateApplication").should(
"have.nested.property",

View File

@ -52,6 +52,8 @@
"enablePublicAccess": ".slider",
"closeBtn": ".bp3-dialog-close-button",
"applicationName": ".t--application-name",
"editingAppName": "bp3-editable-text-editing",
"portalMenuItem": ".bp3-portal .bp3-menu-item",
"profileMenu": ".bp3-popover-wrapper.profile-menu",
"signOutIcon": ".t--logout-icon",
"headerAppSmithLogo": ".t--Appsmith-logo-image",

View File

@ -241,6 +241,17 @@ Cypress.Commands.add("launchApp", (appName) => {
);
});
Cypress.Commands.add("AppSetupForRename", () => {
cy.get(homePage.applicationName).then(($appName) => {
if (!$appName.hasClass(homePage.editingAppName)) {
cy.get(homePage.applicationName).click();
cy.get(homePage.portalMenuItem)
.contains("Rename", { matchCase: false })
.click();
}
});
});
Cypress.Commands.add("CreateAppForOrg", (orgName, appname) => {
cy.get(homePage.orgList.concat(orgName).concat(homePage.createAppFrOrg))
.scrollIntoView()
@ -251,8 +262,11 @@ Cypress.Commands.add("CreateAppForOrg", (orgName, appname) => {
"response.body.responseMeta.status",
201,
);
cy.get("#loading").should("not.exist");
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1000);
cy.wait(2000);
cy.AppSetupForRename();
cy.get(homePage.applicationName).type(appname + "{enter}");
cy.wait("@updateApplication").should(
"have.nested.property",
@ -272,8 +286,9 @@ Cypress.Commands.add("CreateAppInFirstListedOrg", (appname) => {
);
cy.get("#loading").should("not.exist");
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1000);
cy.wait(2000);
cy.AppSetupForRename();
cy.get(homePage.applicationName).type(appname + "{enter}");
cy.wait("@updateApplication").should(
"have.nested.property",

View File

@ -1,32 +1,15 @@
import React, {
useState,
useEffect,
useMemo,
useCallback,
useContext,
} from "react";
import {
EditableText as BlueprintEditableText,
Classes as BlueprintClasses,
} from "@blueprintjs/core";
import styled, { ThemeContext } from "styled-components";
import Text, { TextType } from "./Text";
import Spinner from "./Spinner";
import { CommonComponentProps } from "./common";
import React, { useState, useCallback } from "react";
import styled from "styled-components";
import { noop } from "lodash";
import Icon, { IconSize } from "./Icon";
export enum EditInteractionKind {
SINGLE = "SINGLE",
DOUBLE = "DOUBLE",
}
import { CommonComponentProps } from "./common";
import EditableTextSubComponent, {
EditInteractionKind,
SavingState,
} from "./EditableTextSubComponent";
export enum SavingState {
STARTED = "STARTED",
NOT_STARTED = "NOT_STARTED",
SUCCESS = "SUCCESS",
ERROR = "ERROR",
}
export { EditInteractionKind, SavingState };
export type EditableTextProps = CommonComponentProps & {
defaultValue: string;
@ -64,145 +47,20 @@ export const EditableTextWrapper = styled.div<{
}
`;
const editModeBgcolor = (
isInvalid: boolean,
isEditing: boolean,
savingState: SavingState,
theme: any,
): string => {
if ((isInvalid && isEditing) || savingState === SavingState.ERROR) {
return theme.colors.editableText.dangerBg;
} else if (!isInvalid && isEditing) {
return theme.colors.editableText.bg;
} else {
return "transparent";
}
};
const TextContainer = styled.div<{
isInvalid: boolean;
isEditing: boolean;
bgColor: string;
underline?: boolean;
}>`
display: flex;
align-items: center;
.bp3-editable-text.bp3-editable-text-editing::before,
.bp3-editable-text.bp3-disabled::before {
display: none;
}
&&&
.${BlueprintClasses.EDITABLE_TEXT_CONTENT},
&&&
.${BlueprintClasses.EDITABLE_TEXT_INPUT} {
font-size: ${(props) => props.theme.typography.p1.fontSize}px;
line-height: ${(props) => props.theme.typography.p1.lineHeight}px;
letter-spacing: ${(props) => props.theme.typography.p1.letterSpacing}px;
font-weight: ${(props) => props.theme.typography.p1.fontWeight};
}
&&& .${BlueprintClasses.EDITABLE_TEXT_CONTENT} {
cursor: pointer;
color: ${(props) => props.theme.colors.editableText.color};
overflow: hidden;
text-overflow: ellipsis;
${(props) => (props.isEditing ? "display: none" : "display: block")};
width: fit-content !important;
min-width: auto !important;
}
&&& .${BlueprintClasses.EDITABLE_TEXT_CONTENT}:hover {
${(props) =>
props.underline && !props.isEditing
? `
border-bottom-style: solid;
border-bottom-width: 1px;
width: fit-content;
`
: null}
}
&&& .${BlueprintClasses.EDITABLE_TEXT_INPUT} {
border: none;
outline: none;
height: ${(props) => props.theme.spaces[14] + 1}px;
color: ${(props) => props.theme.colors.editableText.color};
min-width: 100%;
border-radius: ${(props) => props.theme.spaces[0]}px;
}
&&& .${BlueprintClasses.EDITABLE_TEXT} {
overflow: hidden;
height: ${(props) => props.theme.spaces[14] + 1}px;
padding: ${(props) => props.theme.spaces[4]}px
${(props) => props.theme.spaces[5]}px;
width: calc(100% - 40px);
background-color: ${(props) => props.bgColor};
}
.icon-wrapper {
background-color: ${(props) => props.bgColor};
}
`;
const IconWrapper = styled.div`
width: ${(props) => props.theme.spaces[15]}px;
padding-right: ${(props) => props.theme.spaces[5]}px;
display: flex;
align-items: center;
justify-content: flex-end;
`;
export function EditableText(props: EditableTextProps) {
const {
defaultValue,
isEditingDefault,
isError,
isInvalid: inputValidation,
onBlur,
onTextChanged,
valueTransform,
savingState: defaultSavingState,
...others
} = props;
const [isEditing, setIsEditing] = useState(!!isEditingDefault);
const [value, setValue] = useState(defaultValue);
const [lastValidValue, setLastValidValue] = useState(defaultValue);
const [isInvalid, setIsInvalid] = useState<string | boolean>(false);
const [changeStarted, setChangeStarted] = useState<boolean>(false);
const [savingState, setSavingState] = useState<SavingState>(
SavingState.NOT_STARTED,
);
useEffect(() => {
if (isError) {
// if there is any error occurs while saving appname.
// last saved app name will be shown to user.
setValue(defaultValue);
}
}, [isError]);
useEffect(() => {
setSavingState(props.savingState);
}, [props.savingState]);
useEffect(() => {
setValue(defaultValue);
}, [defaultValue]);
useEffect(() => {
setIsEditing(!!isEditingDefault);
}, [defaultValue, isEditingDefault]);
useEffect(() => {
if (props.forceDefault === true) setValue(defaultValue);
}, [props.forceDefault, defaultValue]);
const theme = useContext(ThemeContext);
const bgColor = useMemo(
() => editModeBgcolor(!!isInvalid, isEditing, savingState, theme),
[isInvalid, isEditing, savingState, theme],
);
const editMode = useCallback(
(e: React.MouseEvent) => {
setIsEditing(true);
@ -214,61 +72,6 @@ export function EditableText(props: EditableTextProps) {
[inputValidation, defaultValue],
);
const onConfirm = useCallback(
(_value: string) => {
const finalVal: string = _value.trim();
if (savingState === SavingState.ERROR || isInvalid || finalVal === "") {
setValue(lastValidValue);
onBlur && onBlur(lastValidValue);
setSavingState(SavingState.NOT_STARTED);
}
if (changeStarted) {
onTextChanged && onTextChanged(finalVal);
}
if (finalVal && finalVal !== defaultValue) {
onBlur && onBlur(finalVal);
}
setIsEditing(false);
setChangeStarted(false);
},
[
changeStarted,
savingState,
isInvalid,
lastValidValue,
onBlur,
onTextChanged,
],
);
const onInputchange = useCallback(
(_value: string) => {
let finalVal: string = _value.indexOf(" ") === 0 ? _value.trim() : _value;
if (valueTransform) {
finalVal = valueTransform(finalVal);
}
const errorMessage = inputValidation && inputValidation(finalVal);
const error = errorMessage ? errorMessage : false;
if (!error && finalVal !== "") {
setLastValidValue(finalVal);
onTextChanged && onTextChanged(finalVal);
}
setValue(finalVal);
setIsInvalid(error);
setChangeStarted(true);
},
[inputValidation, onTextChanged],
);
const iconName =
!isEditing && savingState === SavingState.NOT_STARTED && !props.hideEditIcon
? "edit"
: !isEditing && savingState === SavingState.SUCCESS
? "success"
: savingState === SavingState.ERROR || (isEditing && !!isInvalid)
? "error"
: undefined;
const nonEditMode = () => {
if (!isEditing && savingState === SavingState.SUCCESS) {
setSavingState(SavingState.NOT_STARTED);
@ -290,41 +93,19 @@ export function EditableText(props: EditableTextProps) {
}
onMouseEnter={nonEditMode}
>
<TextContainer
bgColor={bgColor}
className="editable-text-container"
data-cy={props.cypressSelector}
<EditableTextSubComponent
defaultSavingState={defaultSavingState}
defaultValue={defaultValue}
inputValidation={inputValidation}
isEditing={isEditing}
isInvalid={!!isInvalid}
underline={props.underline}
>
<BlueprintEditableText
className={props.className}
disabled={!isEditing}
isEditing={isEditing}
onCancel={onConfirm}
onChange={onInputchange}
onConfirm={onConfirm}
placeholder={props.placeholder || defaultValue}
selectAllOnFocus
value={value}
/>
{savingState === SavingState.STARTED ? (
<IconWrapper className="icon-wrapper">
<Spinner size={IconSize.XL} />
</IconWrapper>
) : value && !props.hideEditIcon ? (
<IconWrapper className="icon-wrapper">
<Icon name={iconName} size={IconSize.XL} />
</IconWrapper>
) : null}
</TextContainer>
{isEditing && !!isInvalid ? (
<Text className="error-message" type={TextType.P2}>
{isInvalid}
</Text>
) : null}
isEditingDefault={isEditingDefault}
isInvalid={isInvalid}
savingState={savingState}
setIsEditing={setIsEditing}
setIsInvalid={setIsInvalid}
setSavingState={setSavingState}
{...others}
/>
</EditableTextWrapper>
);
}

View File

@ -0,0 +1,311 @@
import React, {
useState,
useEffect,
useMemo,
useCallback,
useContext,
} from "react";
import {
EditableText as BlueprintEditableText,
Classes as BlueprintClasses,
} from "@blueprintjs/core";
import styled, { ThemeContext } from "styled-components";
import { noop } from "lodash";
import Text, { TextType } from "./Text";
import Spinner from "./Spinner";
import { CommonComponentProps } from "./common";
import Icon, { IconSize } from "./Icon";
export enum EditInteractionKind {
SINGLE = "SINGLE",
DOUBLE = "DOUBLE",
}
export enum SavingState {
STARTED = "STARTED",
NOT_STARTED = "NOT_STARTED",
SUCCESS = "SUCCESS",
ERROR = "ERROR",
}
export type EditableTextSubComponentProps = CommonComponentProps & {
defaultValue: string;
placeholder?: string;
editInteractionKind: EditInteractionKind;
defaultSavingState: SavingState;
savingState: SavingState;
setSavingState: typeof noop;
onBlur?: (value: string) => void;
onTextChanged?: (value: string) => void;
valueTransform?: (value: string) => string;
isEditingDefault?: boolean;
isEditing: boolean;
forceDefault?: boolean;
updating?: boolean;
setIsEditing: typeof noop;
inputValidation?: (value: string) => string | boolean;
isInvalid: string | boolean;
setIsInvalid: typeof noop;
hideEditIcon?: boolean;
fill?: boolean;
underline?: boolean;
isError?: boolean;
};
export const EditableTextWrapper = styled.div<{
filled: boolean;
}>`
${(props) =>
!props.filled
? `
width: 243px;
`
: `
width: 100%;
flex: 1;
`}
.error-message {
margin-left: ${(props) => props.theme.spaces[5]}px;
color: ${(props) => props.theme.colors.danger.main};
}
`;
const editModeBgcolor = (
isInvalid: boolean,
isEditing: boolean,
savingState: SavingState,
theme: any,
): string => {
if ((isInvalid && isEditing) || savingState === SavingState.ERROR) {
return theme.colors.editableText.dangerBg;
} else if (!isInvalid && isEditing) {
return theme.colors.editableText.bg;
} else {
return "transparent";
}
};
const TextContainer = styled.div<{
isInvalid: boolean;
isEditing: boolean;
bgColor: string;
underline?: boolean;
}>`
display: flex;
align-items: center;
.bp3-editable-text.bp3-editable-text-editing::before,
.bp3-editable-text.bp3-disabled::before {
display: none;
}
&&&
.${BlueprintClasses.EDITABLE_TEXT_CONTENT},
&&&
.${BlueprintClasses.EDITABLE_TEXT_INPUT} {
font-size: ${(props) => props.theme.typography.p1.fontSize}px;
line-height: ${(props) => props.theme.typography.p1.lineHeight}px;
letter-spacing: ${(props) => props.theme.typography.p1.letterSpacing}px;
font-weight: ${(props) => props.theme.typography.p1.fontWeight};
}
&&& .${BlueprintClasses.EDITABLE_TEXT_CONTENT} {
cursor: pointer;
color: ${(props) => props.theme.colors.editableText.color};
overflow: hidden;
text-overflow: ellipsis;
${(props) => (props.isEditing ? "display: none" : "display: block")};
width: fit-content !important;
min-width: auto !important;
}
&&& .${BlueprintClasses.EDITABLE_TEXT_CONTENT}:hover {
${(props) =>
props.underline && !props.isEditing
? `
border-bottom-style: solid;
border-bottom-width: 1px;
width: fit-content;
`
: null}
}
&&& .${BlueprintClasses.EDITABLE_TEXT_INPUT} {
border: none;
outline: none;
height: ${(props) => props.theme.spaces[14] + 1}px;
color: ${(props) => props.theme.colors.editableText.color};
min-width: 100%;
border-radius: ${(props) => props.theme.spaces[0]}px;
}
&&& .${BlueprintClasses.EDITABLE_TEXT} {
overflow: hidden;
height: ${(props) => props.theme.spaces[14] + 1}px;
padding: ${(props) => props.theme.spaces[4]}px
${(props) => props.theme.spaces[5]}px;
width: calc(100% - 40px);
background-color: ${(props) => props.bgColor};
}
.icon-wrapper {
background-color: ${(props) => props.bgColor};
}
`;
const IconWrapper = styled.div`
width: ${(props) => props.theme.spaces[15]}px;
padding-right: ${(props) => props.theme.spaces[5]}px;
display: flex;
align-items: center;
justify-content: flex-end;
`;
export function EditableTextSubComponent(props: EditableTextSubComponentProps) {
const {
defaultValue,
inputValidation,
isEditing,
isEditingDefault,
isError,
isInvalid,
onBlur,
onTextChanged,
savingState,
setIsEditing,
setIsInvalid,
setSavingState,
valueTransform,
} = props;
const [value, setValue] = useState(defaultValue);
const [lastValidValue, setLastValidValue] = useState(defaultValue);
const [changeStarted, setChangeStarted] = useState<boolean>(false);
useEffect(() => {
if (isError) {
// if there is any error occurs while saving appname.
// last saved app name will be shown to user.
setValue(defaultValue);
}
}, [isError]);
useEffect(() => {
setSavingState(props.defaultSavingState);
}, [props.defaultSavingState]);
useEffect(() => {
setValue(defaultValue);
}, [defaultValue]);
useEffect(() => {
setIsEditing(!!isEditingDefault);
}, [defaultValue, isEditingDefault]);
useEffect(() => {
if (props.forceDefault === true) setValue(defaultValue);
}, [props.forceDefault, defaultValue]);
const theme = useContext(ThemeContext);
const bgColor = useMemo(
() => editModeBgcolor(!!isInvalid, isEditing, savingState, theme),
[isInvalid, isEditing, savingState, theme],
);
const onConfirm = useCallback(
(_value: string) => {
const finalVal: string = _value.trim();
if (savingState === SavingState.ERROR || isInvalid || finalVal === "") {
setValue(lastValidValue);
onBlur && onBlur(lastValidValue);
setSavingState(SavingState.NOT_STARTED);
}
if (changeStarted) {
onTextChanged && onTextChanged(finalVal);
}
if (finalVal && finalVal !== defaultValue) {
onBlur && onBlur(finalVal);
}
setIsEditing(false);
setChangeStarted(false);
},
[
changeStarted,
savingState,
isInvalid,
lastValidValue,
onBlur,
onTextChanged,
],
);
const onInputchange = useCallback(
(_value: string) => {
let finalVal: string = _value.indexOf(" ") === 0 ? _value.trim() : _value;
if (valueTransform) {
finalVal = valueTransform(finalVal);
}
const errorMessage = inputValidation && inputValidation(finalVal);
const error = errorMessage ? errorMessage : false;
if (!error && finalVal !== "") {
setLastValidValue(finalVal);
onTextChanged && onTextChanged(finalVal);
}
setValue(finalVal);
setIsInvalid(error);
setChangeStarted(true);
},
[inputValidation, onTextChanged],
);
const iconName =
!isEditing && savingState === SavingState.NOT_STARTED && !props.hideEditIcon
? "edit"
: !isEditing && savingState === SavingState.SUCCESS
? "success"
: savingState === SavingState.ERROR || (isEditing && !!isInvalid)
? "error"
: undefined;
return (
<>
<TextContainer
bgColor={bgColor}
className="editable-text-container"
data-cy={props.cypressSelector}
isEditing={isEditing}
isInvalid={!!isInvalid}
underline={props.underline}
>
<BlueprintEditableText
className={props.className}
disabled={!isEditing}
isEditing={isEditing}
onCancel={onConfirm}
onChange={onInputchange}
onConfirm={onConfirm}
placeholder={props.placeholder || defaultValue}
selectAllOnFocus
value={value}
/>
{savingState === SavingState.STARTED ? (
<IconWrapper className="icon-wrapper">
<Spinner size={IconSize.XL} />
</IconWrapper>
) : value && !props.hideEditIcon ? (
<IconWrapper className="icon-wrapper">
<Icon name={iconName} size={IconSize.XL} />
</IconWrapper>
) : null}
</TextContainer>
{isEditing && !!isInvalid ? (
<Text className="error-message" type={TextType.P2}>
{isInvalid}
</Text>
) : null}
</>
);
}
export default EditableTextSubComponent;

View File

@ -67,6 +67,7 @@ import { ReactComponent as Unpin } from "assets/icons/comments/unpin.svg";
import { ReactComponent as Reaction } from "assets/icons/comments/reaction.svg";
import { ReactComponent as Reaction2 } from "assets/icons/comments/reaction-2.svg";
import { ReactComponent as Upload } from "assets/icons/ads/upload.svg";
import { ReactComponent as UpArrow } from "assets/icons/ads/upper_arrow.svg";
import { ReactComponent as Download } from "assets/icons/ads/download.svg";
import { ReactComponent as ArrowForwardIcon } from "assets/icons/control/arrow_forward.svg";
import { ReactComponent as CapSolidIcon } from "assets/icons/control/cap_solid.svg";
@ -155,6 +156,7 @@ export const IconCollection = [
"warning",
"warning-triangle",
"downArrow",
"upArrow",
"context-menu",
"duplicate",
"logout",
@ -327,6 +329,9 @@ const Icon = forwardRef(
case "downArrow":
returnIcon = <DownArrow />;
break;
case "upArrow":
returnIcon = <UpArrow />;
break;
case "share":
returnIcon = <ShareIcon />;
break;

View File

@ -350,6 +350,7 @@ export type Theme = {
homePage: any;
sidebarWidth: string;
canvasBottomPadding: number;
navbarMenuHeight: string;
actionsBottomTabInitialHeight: string;
sideNav: {
minWidth: number;
@ -609,6 +610,14 @@ type ColorType = {
bg: string;
};
};
navigationMenu: {
contentActive: string;
backgroundActive: string;
contentInactive: string;
backgroundInactive: string;
label: string;
warning: string;
};
colorSelector: {
shadow: ShadeColor;
checkmark: ShadeColor;
@ -1225,6 +1234,15 @@ const mentionsInput = {
mentionsInviteBtnPlusIcon: "#6A86CE",
};
const navigationMenu = {
contentActive: "#F0F0F0",
backgroundActive: "#222222",
contentInactive: "#858282",
backgroundInactive: "#090707",
label: "#A9A7A7",
warning: "#F22B2B",
};
export const dark: ColorType = {
overlayColor: "#090707cc",
notifications,
@ -1236,6 +1254,7 @@ export const dark: ColorType = {
helpModal,
globalSearch,
comments,
navigationMenu,
selected: darkShades[10],
header: {
separator: darkShades[4],
@ -1704,6 +1723,7 @@ export const light: ColorType = {
modeIconCircleStroke: "#fff",
activeModeIconCircleStroke: "#EBEBEB",
},
navigationMenu,
selected: lightShades[12],
header: {
separator: "#E0DEDE",
@ -2406,6 +2426,7 @@ export const theme: Theme = {
integrationsPageUnusableHeight: "182px",
backBanner: "30px",
canvasBottomPadding: 200,
navbarMenuHeight: "35px",
sideNav: {
maxWidth: 220,
minWidth: 50,

View File

@ -54,6 +54,10 @@ div.bp3-popover-arrow {
background: rgb(3, 179, 101) !important;
}
.t--editor-appname-menu-portal {
z-index: 9 !important;
}
.bp3-popover .bp3-input {
outline: 0;
box-shadow: none;

View File

@ -0,0 +1,74 @@
import React from "react";
import styled from "styled-components";
import { noop } from "lodash";
import { CommonComponentProps } from "components/ads/common";
import EditableTextSubComponent from "components/ads/EditableTextSubComponent";
import { EditInteractionKind, SavingState } from "components/ads/EditableText";
export type EditableAppNameProps = CommonComponentProps & {
defaultValue: string;
placeholder?: string;
editInteractionKind: EditInteractionKind;
defaultSavingState: SavingState;
onClick?: typeof noop;
onBlur?: (value: string) => void;
isEditingDefault?: boolean;
inputValidation?: (value: string) => string | boolean;
hideEditIcon?: boolean;
fill?: boolean;
isError?: boolean;
isEditing: boolean;
setIsEditing: typeof noop;
isInvalid: string | boolean;
setIsInvalid: typeof noop;
savingState: SavingState;
setSavingState: typeof noop;
};
export const EditableAppNameWrapper = styled.div<{
filled: boolean;
}>`
${(props) =>
!props.filled
? `
width: 243px;
`
: `
width: 100%;
flex: 1;
`}
.error-message {
margin-left: ${(props) => props.theme.spaces[5]}px;
color: ${(props) => props.theme.colors.danger.main};
}
`;
export function EditableAppName(props: EditableAppNameProps) {
const { isEditing, onClick, savingState, setSavingState, ...others } = props;
const nonEditMode = () => {
if (!isEditing && savingState === SavingState.SUCCESS) {
setSavingState(SavingState.NOT_STARTED);
}
};
return (
<EditableAppNameWrapper
filled={!!props.fill}
onClick={onClick}
onMouseEnter={nonEditMode}
>
<EditableTextSubComponent
isEditing={isEditing}
savingState={savingState}
setSavingState={setSavingState}
underline={false}
{...others}
/>
</EditableAppNameWrapper>
);
}
export default EditableAppName;

View File

@ -0,0 +1,44 @@
import React from "react";
import { noop } from "lodash";
import {
NavigationMenuItem,
MenuTypes,
MenuItemData,
} from "./NavigationMenuItem";
type NavigationMenuProps = {
menuItems: MenuItemData[] | undefined;
setIsPopoverOpen: typeof noop;
};
export function NavigationMenu(props: NavigationMenuProps) {
const { menuItems, setIsPopoverOpen } = props;
return (
// eslint-disable-next-line
<>
{menuItems?.map((item, idx) => {
return item.type === MenuTypes.PARENT ? (
<NavigationMenuItem
key={idx}
menuItemData={item}
setIsPopoverOpen={setIsPopoverOpen}
>
<NavigationMenu
menuItems={item?.children}
setIsPopoverOpen={setIsPopoverOpen}
/>
</NavigationMenuItem>
) : (
<NavigationMenuItem
key={idx}
menuItemData={item}
setIsPopoverOpen={setIsPopoverOpen}
/>
);
})}
</>
);
}

View File

@ -0,0 +1,160 @@
import { useDispatch, useSelector } from "react-redux";
import { useHistory } from "react-router-dom";
import { noop } from "lodash";
import { Variant } from "components/ads/common";
import { Toaster } from "components/ads/Toast";
import { ThemeProp } from "components/ads/common";
import { setCommentModeInUrl } from "pages/Editor/ToggleModeButton";
import { toggleShowGlobalSearchModal } from "actions/globalSearchActions";
import { areCommentsEnabledForUserAndApp } from "selectors/commentsSelectors";
import { ReduxActionTypes } from "constants/ReduxActionConstants";
import { APPLICATIONS_URL } from "constants/routes";
import { MenuItemData, MenuTypes } from "./NavigationMenuItem";
import { useCallback } from "react";
type NavigationMenuDataProps = ThemeProp & {
applicationId: string | undefined;
editMode: typeof noop;
deploy: typeof noop;
currentDeployLink: string;
};
export const GetNavigationMenuData = ({
applicationId,
currentDeployLink,
deploy,
editMode,
theme,
}: NavigationMenuDataProps): MenuItemData[] => {
const dispatch = useDispatch();
const commentsEnabled = useSelector(areCommentsEnabledForUserAndApp);
const history = useHistory();
const isApplicationIdPresent = !!(applicationId && applicationId.length > 0);
const openExternalLink = useCallback((link: string) => {
if (link) {
window.open(link, "_blank");
}
}, []);
const deleteApplication = () => {
if (applicationId && applicationId.length > 0) {
dispatch({
type: ReduxActionTypes.DELETE_APPLICATION_INIT,
payload: {
applicationId,
},
});
history.push(APPLICATIONS_URL);
} else {
Toaster.show({
text: "Error while deleting Application",
variant: Variant.danger,
});
}
};
return [
{
text: "Rename",
onClick: editMode,
type: MenuTypes.MENU,
isVisible: true,
},
{
text: "View Modes",
type: MenuTypes.PARENT,
isVisible: !!commentsEnabled,
children: [
{
text: "Edit Mode",
label: "E",
onClick: () => setCommentModeInUrl(false),
type: MenuTypes.MENU,
isVisible: true,
},
{
text: "Comment Mode",
label: "C",
onClick: () => setCommentModeInUrl(true),
type: MenuTypes.MENU,
isVisible: true,
},
],
},
{
text: "Deploy",
type: MenuTypes.PARENT,
isVisible: true,
children: [
{
text: "Deploy",
onClick: deploy,
type: MenuTypes.MENU,
isVisible: true,
isOpensNewWindow: true,
},
{
text: "Current Deployed Version",
onClick: () => openExternalLink(currentDeployLink),
type: MenuTypes.MENU,
isVisible: true,
isOpensNewWindow: true,
},
],
},
{
text: "Shortcuts",
onClick: () => dispatch(toggleShowGlobalSearchModal()),
type: MenuTypes.MENU,
isVisible: true,
},
{
text: "Help",
type: MenuTypes.PARENT,
isVisible: true,
children: [
{
text: "Community Forum",
onClick: () => openExternalLink("https://community.appsmith.com/"),
type: MenuTypes.MENU,
isVisible: true,
isOpensNewWindow: true,
},
{
text: "Discord Channel",
onClick: () => openExternalLink("https://discord.gg/9deFW7q4kB"),
type: MenuTypes.MENU,
isVisible: true,
isOpensNewWindow: true,
},
{
text: "Github",
onClick: () =>
openExternalLink("https://github.com/appsmithorg/appsmith/"),
type: MenuTypes.MENU,
isVisible: true,
isOpensNewWindow: true,
},
{
text: "Documentation",
onClick: () => openExternalLink("https://docs.appsmith.com/"),
type: MenuTypes.MENU,
isVisible: true,
isOpensNewWindow: true,
},
],
},
{
text: "Delete Application",
confirmText: "Are you sure?",
onClick: deleteApplication,
type: MenuTypes.RECONFIRM,
isVisible: isApplicationIdPresent,
style: { color: theme.colors.navigationMenu.warning },
},
];
};

View File

@ -0,0 +1,140 @@
import React, { useState } from "react";
import styled from "styled-components";
import { Classes, MenuItem } from "@blueprintjs/core";
import { noop } from "lodash";
import { CommonComponentProps } from "components/ads/common";
import Icon, { IconSize } from "components/ads/Icon";
import AnalyticsUtil from "utils/AnalyticsUtil";
export enum MenuTypes {
MENU = "menu",
PARENT = "parent",
RECONFIRM = "re-confirm",
}
export interface MenuItemData {
text: string;
label?: string;
onClick?: typeof noop;
children?: MenuItemData[];
type: MenuTypes;
isVisible: boolean;
confirmText?: string;
isOpensNewWindow?: boolean | undefined;
style?: React.CSSProperties;
}
const StyledMenuItem = styled(MenuItem)`
width: 240px;
background: ${(props) =>
props.theme.colors.navigationMenu.backgroundInactive};
color: ${(props) => props.theme.colors.navigationMenu.contentInactive};
border-radius: 0;
height: ${(props) => props.theme.navbarMenuHeight};
&&&:hover {
color: ${(props) => props.theme.colors.navigationMenu.contentActive};
background: ${(props) =>
props.theme.colors.navigationMenu.backgroundActive};
background-color: ${(props) =>
props.theme.colors.navigationMenu.backgroundActive};
}
> .${Classes.MENU_ITEM_LABEL} {
color: ${(props) => props.theme.colors.navigationMenu.label};
}
`;
type NavigationMenuItemProps = CommonComponentProps & {
menuItemData: MenuItemData;
setIsPopoverOpen: typeof noop;
children?: React.ReactNode;
};
export function NavigationMenuItem({
children,
menuItemData,
setIsPopoverOpen,
}: NavigationMenuItemProps) {
const {
confirmText,
isOpensNewWindow,
isVisible,
label,
onClick,
style,
text,
} = menuItemData;
const [confirm, setConfirm] = useState({
isConfirm: false,
text: text,
});
if (!isVisible) return null;
const labelElement = isOpensNewWindow && (
<Icon name="open" size={IconSize.LARGE} />
);
const handleClick = (e: React.SyntheticEvent) => {
setIsPopoverOpen(false);
if (onClick) onClick(e);
AnalyticsUtil.logEvent("APP_MENU_OPTION_CLICK", {
option: text,
});
};
const handleReconfirmClick = (e: React.SyntheticEvent) => {
if (!confirm.isConfirm && confirmText) {
setConfirm({
isConfirm: true,
text: confirmText,
});
e.preventDefault();
e.stopPropagation();
} else if (onClick) {
setIsPopoverOpen(false);
onClick(e);
AnalyticsUtil.logEvent("APP_MENU_OPTION_CLICK", {
option: text,
});
setConfirm({
isConfirm: false,
text: text,
});
}
};
switch (menuItemData.type) {
case MenuTypes.MENU:
return (
<StyledMenuItem
label={label}
labelElement={labelElement}
onClick={handleClick}
style={style}
text={text}
/>
);
case MenuTypes.PARENT:
return (
<StyledMenuItem label={label} style={style} text={confirm.text}>
{children}
</StyledMenuItem>
);
case MenuTypes.RECONFIRM:
return (
<StyledMenuItem
label={label}
onClick={handleReconfirmClick}
style={style}
text={confirm.text}
/>
);
}
return null;
}

View File

@ -0,0 +1,250 @@
import React, { useState, useCallback } from "react";
import styled, { withTheme } from "styled-components";
import { Classes, Menu, Position } from "@blueprintjs/core";
import { Classes as Popover2Classes, Popover2 } from "@blueprintjs/popover2";
import { noop } from "lodash";
import { Variant } from "components/ads/common";
import { Toaster } from "components/ads/Toast";
import Icon, { IconSize } from "components/ads/Icon";
import { SavingState } from "components/ads/EditableTextSubComponent";
import { EditInteractionKind } from "components/ads/EditableText";
import { CommonComponentProps, ThemeProp } from "components/ads/common";
import { getTypographyByKey } from "constants/DefaultTheme";
import EditableAppName from "./EditableAppName";
import { GetNavigationMenuData } from "./NavigationMenuData";
import { NavigationMenu } from "./NavigationMenu";
type EditorAppNameProps = CommonComponentProps &
ThemeProp & {
applicationId: string | undefined;
defaultValue: string;
placeholder?: string;
editInteractionKind: EditInteractionKind;
defaultSavingState: SavingState;
deploy: typeof noop;
onBlur?: (value: string) => void;
isEditingDefault?: boolean;
inputValidation?: (value: string) => string | boolean;
hideEditIcon?: boolean;
fill?: boolean;
isError?: boolean;
isNewApp: boolean;
currentDeployLink: string;
isPopoverOpen: boolean;
setIsPopoverOpen: typeof noop;
};
const Container = styled.div<{ isPopoverOpen: boolean }>`
display: flex;
cursor: pointer;
${(props) =>
props.isPopoverOpen &&
`
background-color: ${props.theme.colors.navigationMenu.backgroundInactive};
> span {
background-color: ${props.theme.colors.navigationMenu.backgroundInactive};
}`}
&:hover {
background-color: ${(props) =>
props.theme.colors.navigationMenu.backgroundInactive};
> span {
background-color: ${(props) =>
props.theme.colors.navigationMenu.backgroundInactive};
}
}
> span {
height: ${(props) => props.theme.smallHeaderHeight};
}
& .${Popover2Classes.POPOVER2_TARGET} {
height: 100%;
}
& .${Classes.EDITABLE_TEXT} {
height: ${(props) => props.theme.smallHeaderHeight} !important;
display: block;
cursor: pointer;
}
&&&& .${Classes.EDITABLE_TEXT}, &&&& .${Classes.EDITABLE_TEXT_EDITING} {
padding: 0 ${(props) => props.theme.spaces[0]}px;
width: 100%;
}
&&&& .${Classes.EDITABLE_TEXT_CONTENT}, &&&& .${Classes.EDITABLE_TEXT_INPUT} {
display: block;
${(props) => getTypographyByKey(props, "h4")};
line-height: 19px !important;
padding: 8px 5px;
}
&&&& .${Classes.EDITABLE_TEXT_INPUT} {
margin-right: 20px;
}
`;
const StyledIcon = styled(Icon)`
height: 100%;
padding-right: 10px;
align-self: center;
svg path {
fill: ${(props) => props.theme.colors.navigationMenu.contentActive};
}
`;
const StyledMenu = styled(Menu)`
background: ${(props) =>
props.theme.colors.navigationMenu.backgroundInactive};
color: ${(props) => props.theme.colors.navigationMenu.contentInactive};
${(props) => getTypographyByKey(props, "p1")};
border-radius: 0;
padding: 0;
&&& .${Classes.MENU}, &&& .${Classes.MENU_SUBMENU} {
background: ${(props) =>
props.theme.colors.navigationMenu.backgroundInactive};
color: ${(props) => props.theme.colors.navigationMenu.contentInactive};
border-radius: 0;
padding: 0;
.${Classes.ICON} {
color: ${(props) => props.theme.colors.navigationMenu.contentInactive};
}
.${Classes.POPOVER_TARGET}.${Classes.POPOVER_OPEN} > .${Classes.MENU_ITEM} {
color: ${(props) => props.theme.colors.navigationMenu.contentActive};
background: ${(props) =>
props.theme.colors.navigationMenu.backgroundActive};
background-color: ${(props) =>
props.theme.colors.navigationMenu.backgroundActive};
}
}
&&& .${Classes.MENU_SUBMENU}:hover {
.${Classes.ICON} {
color: ${(props) => props.theme.colors.navigationMenu.contentActive};
}
}
`;
export function EditorAppName(props: EditorAppNameProps) {
const {
applicationId,
currentDeployLink,
defaultSavingState,
defaultValue,
deploy,
isNewApp,
isPopoverOpen,
setIsPopoverOpen,
theme,
} = props;
const [isEditingDefault, setIsEditingDefault] = useState(isNewApp);
const [isEditing, setIsEditing] = useState(!!isEditingDefault);
const [isInvalid, setIsInvalid] = useState<string | boolean>(false);
const [savingState, setSavingState] = useState<SavingState>(
SavingState.NOT_STARTED,
);
const onBlur = (value: string) => {
if (props.onBlur) props.onBlur(value);
setIsEditingDefault(false);
};
const inputValidation = (value: string) => {
if (value.trim() === "") {
Toaster.show({
text: "Application name can't be empty",
variant: Variant.danger,
});
}
return false;
};
const editMode = useCallback(
(e: React.MouseEvent) => {
setIsEditing(true);
const errorMessage = inputValidation && inputValidation(defaultValue);
setIsInvalid(errorMessage ? errorMessage : false);
e.preventDefault();
e.stopPropagation();
},
[inputValidation, defaultValue],
);
const handleAppNameClick = useCallback(() => {
if (!isEditing) {
setIsPopoverOpen((isOpen: boolean) => {
return !isOpen;
});
}
}, [isEditing]);
const handleOnInteraction = useCallback((nextOpenState: boolean) => {
if (!nextOpenState) {
setIsPopoverOpen(false);
}
}, []);
const NavigationMenuData = GetNavigationMenuData({
applicationId,
currentDeployLink,
editMode,
deploy,
theme,
});
const NavigationMenuItems = (
<StyledMenu>
<NavigationMenu
menuItems={NavigationMenuData}
setIsPopoverOpen={setIsPopoverOpen}
/>
</StyledMenu>
);
return defaultValue !== "" ? (
<Popover2
autoFocus={false}
content={NavigationMenuItems}
isOpen={isPopoverOpen}
minimal
onInteraction={handleOnInteraction}
portalClassName="t--editor-appname-menu-portal"
position={Position.BOTTOM_RIGHT}
>
<Container isPopoverOpen={isPopoverOpen} onClick={handleAppNameClick}>
<EditableAppName
className={props.className}
defaultSavingState={defaultSavingState}
defaultValue={defaultValue}
editInteractionKind={props.editInteractionKind}
fill={props.fill}
hideEditIcon
inputValidation={inputValidation}
isEditing={isEditing}
isEditingDefault={isEditingDefault}
isError={props.isError}
isInvalid={isInvalid}
onBlur={onBlur}
placeholder={props.placeholder}
savingState={savingState}
setIsEditing={setIsEditing}
setIsInvalid={setIsInvalid}
setSavingState={setSavingState}
/>
{!isEditing && (
<StyledIcon
fillColor={theme.colors.navigationMenu.contentInactive}
name="downArrow"
size={IconSize.XXS}
/>
)}
</Container>
</Popover2>
) : null;
}
export default withTheme(EditorAppName);

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import styled, { ThemeProvider } from "styled-components";
import { Classes as Popover2Classes } from "@blueprintjs/popover2";
import {
ApplicationPayload,
ReduxActionTypes,
@ -35,7 +36,7 @@ import {
getIsErroredSavingAppName,
showAppInviteUsersDialogSelector,
} from "selectors/applicationSelectors";
import EditableAppName from "./EditableAppName";
import EditorAppName from "./EditorAppName";
import Boxed from "components/editorComponents/Onboarding/Boxed";
import OnboardingHelper from "components/editorComponents/Onboarding/Helper";
import { OnboardingStep } from "constants/OnboardingConstants";
@ -106,6 +107,10 @@ const HeaderSection = styled.div`
:nth-child(3) {
justify-content: flex-end;
}
> .${Popover2Classes.POPOVER2_TARGET} {
max-width: calc(100% - 50px);
min-width: 100px;
}
`;
const AppsmithLogoImg = styled.img`
@ -191,6 +196,8 @@ export function EditorHeader(props: EditorHeaderProps) {
};
}, [lastUpdatedTime]);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const handlePublish = () => {
if (applicationId) {
publishApplication(applicationId);
@ -253,9 +260,18 @@ export function EditorHeader(props: EditorHeaderProps) {
/>
</Link>
<Boxed step={OnboardingStep.FINISH}>
<EditableAppName
<EditorAppName
applicationId={applicationId}
className="t--application-name editable-application-name"
currentDeployLink={getApplicationViewerPageURL(
applicationId,
pageId,
)}
defaultSavingState={
isSavingName ? SavingState.STARTED : SavingState.NOT_STARTED
}
defaultValue={currentApplication?.name || ""}
deploy={handlePublish}
editInteractionKind={EditInteractionKind.SINGLE}
fill
isError={isErroredSavingName}
@ -263,17 +279,16 @@ export function EditorHeader(props: EditorHeaderProps) {
applicationList.filter((el) => el.id === applicationId).length >
0
}
isPopoverOpen={isPopoverOpen}
onBlur={(value: string) =>
updateApplicationDispatch(applicationId || "", {
name: value,
currentApp: true,
})
}
savingState={
isSavingName ? SavingState.STARTED : SavingState.NOT_STARTED
}
setIsPopoverOpen={setIsPopoverOpen}
/>
<ToggleModeButton />
<ToggleModeButton showSelectedMode={!isPopoverOpen} />
</Boxed>
</HeaderSection>
<HeaderSection>

View File

@ -23,6 +23,8 @@ import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants";
describe("Select all hotkey", () => {
const mockGetIsFetchingPage = jest.spyOn(utilities, "getIsFetchingPage");
const spyGetCanvasWidgetDsl = jest.spyOn(utilities, "getCanvasWidgetDsl");
Element.prototype.scrollIntoView = jest.fn();
function UpdatedMainContainer({ dsl }: any) {
useMockDsl(dsl);
return <MainContainer />;

View File

@ -43,7 +43,11 @@ const getShowCommentsButtonToolTip = () => {
const setShowCommentsButtonToolTip = (value = "") =>
localStorage.setItem("ShowCommentsButtonToolTip", value);
const ModeButton = styled.div<{ active: boolean; type: string }>`
const ModeButton = styled.div<{
active: boolean;
showSelectedMode: boolean;
type: string;
}>`
position: relative;
display: flex;
align-items: center;
@ -53,7 +57,7 @@ const ModeButton = styled.div<{ active: boolean; type: string }>`
height: ${(props) => props.theme.smallHeaderHeight};
width: ${(props) => props.theme.smallHeaderHeight};
background: ${(props) =>
props.active
props.active && props.showSelectedMode
? props.theme.colors.comments.activeModeBackground
: "transparent"};
@ -91,6 +95,7 @@ const Container = styled.div`
display: flex;
flex: 1;
z-index: ${Indices.Layer1};
margin-left: ${(props) => props.theme.smallHeaderHeight};
`;
/**
@ -222,11 +227,13 @@ function ViewOrEditMode({ mode }: { mode?: APP_MODE }) {
function CommentModeBtn({
handleSetCommentModeButton,
isCommentMode,
showSelectedMode,
showUnreadIndicator,
}: {
handleSetCommentModeButton: () => void;
isCommentMode: boolean;
showUnreadIndicator: boolean;
showSelectedMode: boolean;
}) {
const CommentModeIcon = showUnreadIndicator ? CommentModeUnread : CommentMode;
@ -235,6 +242,7 @@ function CommentModeBtn({
active={isCommentMode}
className="t--switch-comment-mode-on"
onClick={handleSetCommentModeButton}
showSelectedMode={showSelectedMode}
type="stroke"
>
<TooltipComponent
@ -253,7 +261,13 @@ function CommentModeBtn({
);
}
function ToggleCommentModeButton() {
type ToggleCommentModeButtonProps = {
showSelectedMode?: boolean;
};
function ToggleCommentModeButton({
showSelectedMode = true,
}: ToggleCommentModeButtonProps) {
const commentsEnabled = useSelector(areCommentsEnabledForUserAndAppSelector);
const isCommentMode = useSelector(commentModeSelector);
const currentUser = useSelector(getCurrentUser);
@ -300,6 +314,7 @@ function ToggleCommentModeButton() {
<ModeButton
active={!isCommentMode}
onClick={() => setCommentModeInUrl(false)}
showSelectedMode={showSelectedMode}
type="fill"
>
<ViewOrEditMode mode={mode} />
@ -313,6 +328,7 @@ function ToggleCommentModeButton() {
handleSetCommentModeButton,
isCommentMode,
showUnreadIndicator,
showSelectedMode,
}}
/>
</TooltipComponent>

View File

@ -126,6 +126,7 @@ export type EventName =
| "CYCLICAL_DEPENDENCY_ERROR"
| "DISCORD_LINK_CLICK"
| "BINDING_SUCCESS"
| "APP_MENU_OPTION_CLICK"
| "SLASH_COMMAND"
| "DEBUGGER_NEW_ERROR"
| "DEBUGGER_RESOLVED_ERROR";