From 3042adbdeae7435cb083bb253af7b9850b007fa1 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Thu, 15 Jul 2021 16:26:18 +0530 Subject: [PATCH] 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 * addressing review comments * Updating discord link with permanent invite Co-authored-by: Rishabh Saxena Co-authored-by: Rahul R --- .../Applications/UpdateApplication_spec.js | 6 + app/client/cypress/locators/HomePage.json | 2 + app/client/cypress/support/commands.js | 19 +- .../src/components/ads/EditableText.tsx | 265 ++------------- .../ads/EditableTextSubComponent.tsx | 311 ++++++++++++++++++ app/client/src/components/ads/Icon.tsx | 5 + app/client/src/constants/DefaultTheme.tsx | 21 ++ app/client/src/index.css | 4 + .../Editor/EditorAppName/EditableAppName.tsx | 74 +++++ .../Editor/EditorAppName/NavigationMenu.tsx | 44 +++ .../EditorAppName/NavigationMenuData.ts | 160 +++++++++ .../EditorAppName/NavigationMenuItem.tsx | 140 ++++++++ .../src/pages/Editor/EditorAppName/index.tsx | 250 ++++++++++++++ app/client/src/pages/Editor/EditorHeader.tsx | 27 +- .../src/pages/Editor/GlobalHotKeys.test.tsx | 2 + .../src/pages/Editor/ToggleModeButton.tsx | 22 +- app/client/src/utils/AnalyticsUtil.tsx | 1 + 17 files changed, 1100 insertions(+), 253 deletions(-) create mode 100644 app/client/src/components/ads/EditableTextSubComponent.tsx create mode 100644 app/client/src/pages/Editor/EditorAppName/EditableAppName.tsx create mode 100644 app/client/src/pages/Editor/EditorAppName/NavigationMenu.tsx create mode 100644 app/client/src/pages/Editor/EditorAppName/NavigationMenuData.ts create mode 100644 app/client/src/pages/Editor/EditorAppName/NavigationMenuItem.tsx create mode 100644 app/client/src/pages/Editor/EditorAppName/index.tsx diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/UpdateApplication_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/UpdateApplication_spec.js index fa111127a3..692a6de4f3 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/UpdateApplication_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/UpdateApplication_spec.js @@ -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", diff --git a/app/client/cypress/locators/HomePage.json b/app/client/cypress/locators/HomePage.json index d6b0808e4b..b39286846c 100644 --- a/app/client/cypress/locators/HomePage.json +++ b/app/client/cypress/locators/HomePage.json @@ -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", diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js index 90d1f048c2..3ebed18b07 100644 --- a/app/client/cypress/support/commands.js +++ b/app/client/cypress/support/commands.js @@ -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", diff --git a/app/client/src/components/ads/EditableText.tsx b/app/client/src/components/ads/EditableText.tsx index cc80f9a32a..29b5e91e3c 100644 --- a/app/client/src/components/ads/EditableText.tsx +++ b/app/client/src/components/ads/EditableText.tsx @@ -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(false); - const [changeStarted, setChangeStarted] = useState(false); const [savingState, setSavingState] = useState( 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} > - - - - {savingState === SavingState.STARTED ? ( - - - - ) : value && !props.hideEditIcon ? ( - - - - ) : null} - - {isEditing && !!isInvalid ? ( - - {isInvalid} - - ) : null} + isEditingDefault={isEditingDefault} + isInvalid={isInvalid} + savingState={savingState} + setIsEditing={setIsEditing} + setIsInvalid={setIsInvalid} + setSavingState={setSavingState} + {...others} + /> ); } diff --git a/app/client/src/components/ads/EditableTextSubComponent.tsx b/app/client/src/components/ads/EditableTextSubComponent.tsx new file mode 100644 index 0000000000..0ffff7bbd6 --- /dev/null +++ b/app/client/src/components/ads/EditableTextSubComponent.tsx @@ -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(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 ( + <> + + + + {savingState === SavingState.STARTED ? ( + + + + ) : value && !props.hideEditIcon ? ( + + + + ) : null} + + {isEditing && !!isInvalid ? ( + + {isInvalid} + + ) : null} + + ); +} + +export default EditableTextSubComponent; diff --git a/app/client/src/components/ads/Icon.tsx b/app/client/src/components/ads/Icon.tsx index 109f5a704e..b18d3f1161 100644 --- a/app/client/src/components/ads/Icon.tsx +++ b/app/client/src/components/ads/Icon.tsx @@ -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 = ; break; + case "upArrow": + returnIcon = ; + break; case "share": returnIcon = ; break; diff --git a/app/client/src/constants/DefaultTheme.tsx b/app/client/src/constants/DefaultTheme.tsx index 036405a6ce..67240fc2e1 100644 --- a/app/client/src/constants/DefaultTheme.tsx +++ b/app/client/src/constants/DefaultTheme.tsx @@ -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, diff --git a/app/client/src/index.css b/app/client/src/index.css index 84097e0241..d6958db5c5 100755 --- a/app/client/src/index.css +++ b/app/client/src/index.css @@ -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; diff --git a/app/client/src/pages/Editor/EditorAppName/EditableAppName.tsx b/app/client/src/pages/Editor/EditorAppName/EditableAppName.tsx new file mode 100644 index 0000000000..edb75145d6 --- /dev/null +++ b/app/client/src/pages/Editor/EditorAppName/EditableAppName.tsx @@ -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 ( + + + + ); +} + +export default EditableAppName; diff --git a/app/client/src/pages/Editor/EditorAppName/NavigationMenu.tsx b/app/client/src/pages/Editor/EditorAppName/NavigationMenu.tsx new file mode 100644 index 0000000000..6d92a4a762 --- /dev/null +++ b/app/client/src/pages/Editor/EditorAppName/NavigationMenu.tsx @@ -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 ? ( + + + + ) : ( + + ); + })} + + ); +} diff --git a/app/client/src/pages/Editor/EditorAppName/NavigationMenuData.ts b/app/client/src/pages/Editor/EditorAppName/NavigationMenuData.ts new file mode 100644 index 0000000000..ad60dd8d08 --- /dev/null +++ b/app/client/src/pages/Editor/EditorAppName/NavigationMenuData.ts @@ -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 }, + }, + ]; +}; diff --git a/app/client/src/pages/Editor/EditorAppName/NavigationMenuItem.tsx b/app/client/src/pages/Editor/EditorAppName/NavigationMenuItem.tsx new file mode 100644 index 0000000000..b905b04711 --- /dev/null +++ b/app/client/src/pages/Editor/EditorAppName/NavigationMenuItem.tsx @@ -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 && ( + + ); + + 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 ( + + ); + case MenuTypes.PARENT: + return ( + + {children} + + ); + case MenuTypes.RECONFIRM: + return ( + + ); + } + + return null; +} diff --git a/app/client/src/pages/Editor/EditorAppName/index.tsx b/app/client/src/pages/Editor/EditorAppName/index.tsx new file mode 100644 index 0000000000..a9a8fc6f2d --- /dev/null +++ b/app/client/src/pages/Editor/EditorAppName/index.tsx @@ -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(false); + const [savingState, setSavingState] = useState( + 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 = ( + + + + ); + + return defaultValue !== "" ? ( + + + + {!isEditing && ( + + )} + + + ) : null; +} + +export default withTheme(EditorAppName); diff --git a/app/client/src/pages/Editor/EditorHeader.tsx b/app/client/src/pages/Editor/EditorHeader.tsx index 86c90d9d21..853e5722fd 100644 --- a/app/client/src/pages/Editor/EditorHeader.tsx +++ b/app/client/src/pages/Editor/EditorHeader.tsx @@ -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(false); + const handlePublish = () => { if (applicationId) { publishApplication(applicationId); @@ -253,9 +260,18 @@ export function EditorHeader(props: EditorHeaderProps) { /> - 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} /> - + diff --git a/app/client/src/pages/Editor/GlobalHotKeys.test.tsx b/app/client/src/pages/Editor/GlobalHotKeys.test.tsx index 507c40715f..12d23dbac1 100644 --- a/app/client/src/pages/Editor/GlobalHotKeys.test.tsx +++ b/app/client/src/pages/Editor/GlobalHotKeys.test.tsx @@ -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 ; diff --git a/app/client/src/pages/Editor/ToggleModeButton.tsx b/app/client/src/pages/Editor/ToggleModeButton.tsx index eab2e4b1d3..17287f41cd 100644 --- a/app/client/src/pages/Editor/ToggleModeButton.tsx +++ b/app/client/src/pages/Editor/ToggleModeButton.tsx @@ -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" > setCommentModeInUrl(false)} + showSelectedMode={showSelectedMode} type="fill" > @@ -313,6 +328,7 @@ function ToggleCommentModeButton() { handleSetCommentModeButton, isCommentMode, showUnreadIndicator, + showSelectedMode, }} /> diff --git a/app/client/src/utils/AnalyticsUtil.tsx b/app/client/src/utils/AnalyticsUtil.tsx index 0dc9e0174a..b162e66b14 100644 --- a/app/client/src/utils/AnalyticsUtil.tsx +++ b/app/client/src/utils/AnalyticsUtil.tsx @@ -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";