diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/GlobalSearch/GlobalSearch_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/GlobalSearch/GlobalSearch_spec.js new file mode 100644 index 0000000000..5b0763637d --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/GlobalSearch/GlobalSearch_spec.js @@ -0,0 +1,122 @@ +const commonlocators = require("../../../../locators/commonlocators.json"); +const queryLocators = require("../../../../locators/QueryEditor.json"); +const dsl = require("../../../../fixtures/MultipleWidgetDsl.json"); + +describe("GlobalSearch", function() { + before(() => { + cy.addDsl(dsl); + }); + + it("showsAndHidesUsingKeyboardShortcuts", () => { + const isMac = Cypress.platform === "darwin"; + if (isMac) { + cy.wait(2000); + cy.get("body").type("{cmd}{k}"); + cy.get(commonlocators.globalSearchModal); + cy.get("body").type("{esc}"); + cy.get(commonlocators.globalSearchModal).should("not.exist"); + } else { + cy.wait(2000); + cy.get("body").type("{ctrl}{k}"); + cy.get(commonlocators.globalSearchModal); + cy.get("body").type("{esc}"); + cy.get(commonlocators.globalSearchModal).should("not.exist"); + } + }); + + it("selectsWidget", () => { + const table = dsl.dsl.children[2]; + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchInput).type(table.widgetName); + cy.get("body").type("{enter}"); + cy.window() + .its("store") + .invoke("getState") + .then((state) => { + const { selectedWidget } = state.ui.widgetDragResize; + expect(selectedWidget).to.be.equal(table.widgetId); + }); + }); + + it("navigatesToApi", () => { + cy.NavigateToAPI_Panel(); + cy.CreateAPI("SomeApi"); + + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type("Page1"); + cy.get("body").type("{enter}"); + + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type("SomeApi"); + cy.get("body").type("{enter}"); + cy.window() + .its("store") + .invoke("getState") + .then((state) => { + const { actions } = state.entities; + const expectedAction = actions.find( + (actions) => actions.config.name === "SomeApi", + ); + cy.location().should((loc) => { + expect(loc.pathname).includes(expectedAction.config.id); + }); + }); + }); + + it("navigatesToDatasourceHavingAQuery", () => { + cy.window() + .its("store") + .invoke("getState") + .then((state) => { + cy.createPostgresDatasource(); + cy.NavigateToQueryEditor(); + + const { datasources } = state.entities; + const expectedDatasource = + datasources.list[datasources.list.length - 1]; + + cy.contains(".t--datasource-name", expectedDatasource.name) + .find(queryLocators.createQuery) + .click(); + + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type("Page1"); + cy.get("body").type("{enter}"); + + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type(expectedDatasource.name); + cy.get("body").type("{enter}"); + cy.location().should((loc) => { + expect(loc.pathname).includes(expectedDatasource.id); + }); + }); + }); + + it("navigatesToPage", () => { + cy.Createpage("NewPage"); + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type("Page1"); + cy.get("body").type("{enter}"); + cy.window() + .its("store") + .invoke("getState") + .then((state) => { + const { pages } = state.entities.pageList; + const expectedPage = pages.find((page) => page.pageName === "Page1"); + cy.location().should((loc) => { + expect(loc.pathname).includes(expectedPage.pageId); + }); + }); + }); +}); diff --git a/app/client/cypress/locators/commonlocators.json b/app/client/cypress/locators/commonlocators.json index a147b98e6e..e057e1896c 100644 --- a/app/client/cypress/locators/commonlocators.json +++ b/app/client/cypress/locators/commonlocators.json @@ -100,5 +100,9 @@ "filePickerUploadButton": ".uppy-StatusBar-actionBtn--upload", "filePickerOnFilesSelected": ".t--property-control-onfilesselected", "dataType": ".t--property-control-datatype", - "evaluateMsg": ".t--CodeEditor-evaluatedValue p" -} + "evaluateMsg": ".t--CodeEditor-evaluatedValue p", + "globalSearchModal": ".t--global-search-modal", + "globalSearchInput": ".t--global-search-input", + "globalSearchTrigger": ".t--global-search-modal-trigger", + "globalSearchClearInput": ".t--global-clear-input" +} \ No newline at end of file diff --git a/app/client/package.json b/app/client/package.json index 0b9ca59fd5..17b993c423 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -73,11 +73,13 @@ "lodash": "^4.17.19", "loglevel": "^1.6.7", "lottie-web": "^5.7.4", + "marked": "^2.0.0", "moment": "^2.24.0", "moment-timezone": "^0.5.27", "nanoid": "^2.0.4", "node-sass": "^4.11.0", "normalizr": "^3.3.0", + "path-to-regexp": "^6.2.0", "popper.js": "^1.15.0", "prettier": "^1.18.2", "prismjs": "^1.23.0", @@ -112,6 +114,7 @@ "redux-form": "^8.2.6", "redux-saga": "^1.1.3", "reselect": "^4.0.0", + "scroll-into-view-if-needed": "^2.2.26", "shallowequal": "^1.1.0", "smartlook-client": "^4.5.1", "styled-components": "^5.2.0", @@ -172,6 +175,7 @@ "@types/deep-diff": "^1.0.0", "@types/downloadjs": "^1.4.2", "@types/jest": "^24.0.22", + "@types/marked": "^1.2.2", "@types/react-beautiful-dnd": "^11.0.4", "@types/react-select": "^3.0.5", "@types/react-tabs": "^2.3.1", diff --git a/app/client/src/actions/globalSearchActions.ts b/app/client/src/actions/globalSearchActions.ts new file mode 100644 index 0000000000..25383afb95 --- /dev/null +++ b/app/client/src/actions/globalSearchActions.ts @@ -0,0 +1,34 @@ +import { ReduxActionTypes } from "constants/ReduxActionConstants"; +import { RecentEntity } from "components/editorComponents/GlobalSearch/utils"; + +export const setGlobalSearchQuery = (query: string) => ({ + type: ReduxActionTypes.SET_GLOBAL_SEARCH_QUERY, + payload: query, +}); + +export const toggleShowGlobalSearchModal = () => ({ + type: ReduxActionTypes.TOGGLE_SHOW_GLOBAL_SEARCH_MODAL, +}); + +export const updateRecentEntity = (payload: RecentEntity) => ({ + type: ReduxActionTypes.UPDATE_RECENT_ENTITY, + payload, +}); + +export const restoreRecentEntitiesRequest = (payload: string) => ({ + type: ReduxActionTypes.RESTORE_RECENT_ENTITIES_REQUEST, + payload, +}); + +export const restoreRecentEntitiesSuccess = () => ({ + type: ReduxActionTypes.RESTORE_RECENT_ENTITIES_SUCCESS, +}); + +export const resetRecentEntities = () => ({ + type: ReduxActionTypes.RESET_RECENT_ENTITIES, +}); + +export const setRecentEntities = (payload: Array) => ({ + type: ReduxActionTypes.SET_RECENT_ENTITIES, + payload, +}); diff --git a/app/client/src/actions/initActions.ts b/app/client/src/actions/initActions.ts index 9c3854e816..a8267536e2 100644 --- a/app/client/src/actions/initActions.ts +++ b/app/client/src/actions/initActions.ts @@ -14,3 +14,11 @@ export const initEditor = ( pageId, }, }); + +export const resetEditorRequest = () => ({ + type: ReduxActionTypes.RESET_EDITOR_REQUEST, +}); + +export const resetEditorSuccess = () => ({ + type: ReduxActionTypes.RESET_EDITOR_SUCCESS, +}); diff --git a/app/client/src/actions/recentEntityActions.ts b/app/client/src/actions/recentEntityActions.ts new file mode 100644 index 0000000000..967c209667 --- /dev/null +++ b/app/client/src/actions/recentEntityActions.ts @@ -0,0 +1,6 @@ +import { ReduxActionTypes } from "constants/ReduxActionConstants"; + +export const handlePathUpdated = (pathName: string) => ({ + type: ReduxActionTypes.HANDLE_PATH_UPDATED, + payload: { pathName }, +}); diff --git a/app/client/src/actions/widgetActions.tsx b/app/client/src/actions/widgetActions.tsx index 50674ebaf5..c6c6170d63 100644 --- a/app/client/src/actions/widgetActions.tsx +++ b/app/client/src/actions/widgetActions.tsx @@ -66,6 +66,13 @@ export const focusWidget = ( payload: { widgetId }, }); +export const selectWidget = ( + widgetId?: string, +): ReduxAction<{ widgetId?: string }> => ({ + type: ReduxActionTypes.SELECT_WIDGET, + payload: { widgetId }, +}); + export const showModal = (id: string) => { return { type: ReduxActionTypes.SHOW_MODAL, diff --git a/app/client/src/assets/icons/ads/docs.svg b/app/client/src/assets/icons/ads/docs.svg new file mode 100644 index 0000000000..d521ac218e --- /dev/null +++ b/app/client/src/assets/icons/ads/docs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/client/src/assets/icons/ads/entities.svg b/app/client/src/assets/icons/ads/entities.svg new file mode 100644 index 0000000000..f28f50fc3e --- /dev/null +++ b/app/client/src/assets/icons/ads/entities.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/assets/icons/ads/link.svg b/app/client/src/assets/icons/ads/link.svg new file mode 100644 index 0000000000..17266a81cd --- /dev/null +++ b/app/client/src/assets/icons/ads/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/client/src/assets/icons/ads/recent.svg b/app/client/src/assets/icons/ads/recent.svg new file mode 100644 index 0000000000..42e11dc50c --- /dev/null +++ b/app/client/src/assets/icons/ads/recent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/client/src/assets/images/no_search_data.png b/app/client/src/assets/images/no_search_data.png new file mode 100644 index 0000000000..739d01d569 Binary files /dev/null and b/app/client/src/assets/images/no_search_data.png differ diff --git a/app/client/src/components/ads/Icon.tsx b/app/client/src/components/ads/Icon.tsx index 8f95db4ff8..3186419c99 100644 --- a/app/client/src/components/ads/Icon.tsx +++ b/app/client/src/components/ads/Icon.tsx @@ -27,6 +27,8 @@ import { ReactComponent as ArrowLeft } from "assets/icons/ads/arrow-left.svg"; import { ReactComponent as Fork } from "assets/icons/ads/fork.svg"; import { ReactComponent as ChevronLeft } from "assets/icons/ads/chevron_left.svg"; import { ReactComponent as ChevronRight } from "assets/icons/ads/chevron_right.svg"; +import { ReactComponent as LinkIcon } from "assets/icons/ads/link.svg"; +import { ReactComponent as HelpIcon } from "assets/icons/help/help.svg"; import { ReactComponent as CloseModalIcon } from "assets/icons/ads/close-modal.svg"; import { ReactComponent as NoResponseIcon } from "assets/icons/ads/no-response.svg"; import { ReactComponent as LightningIcon } from "assets/icons/ads/lightning.svg"; @@ -120,6 +122,8 @@ export const IconCollection = [ "fork", "chevron-left", "chevron-right", + "link", + "help", "close-modal", "no-response", "lightning", @@ -265,6 +269,12 @@ const Icon = forwardRef( case "chevron-right": returnIcon = ; break; + case "link": + returnIcon = ; + break; + case "help": + returnIcon = ; + break; case "close-modal": returnIcon = ; break; diff --git a/app/client/src/components/designSystems/appsmith/help/DocumentationSearch.tsx b/app/client/src/components/designSystems/appsmith/help/DocumentationSearch.tsx index ca7fd9216b..b34efa53e6 100644 --- a/app/client/src/components/designSystems/appsmith/help/DocumentationSearch.tsx +++ b/app/client/src/components/designSystems/appsmith/help/DocumentationSearch.tsx @@ -290,12 +290,25 @@ const HelpFooter = styled.div` font-size: 6pt; `; -const HelpBody = styled.div` - padding-top: 68px; +const HelpBody = styled.div<{ hideSearch?: boolean }>` + ${(props) => + props.hideSearch + ? ` + padding: ${props.theme.spaces[2]}px; + ` + : ` + padding-top: 68px; + `} flex: 5; `; -type Props = { hitsPerPage: number; defaultRefinement: string; dispatch: any }; +type Props = { + hitsPerPage: number; + defaultRefinement: string; + dispatch: any; + hideSearch?: boolean; + hideMinimizeBtn?: boolean; +}; type State = { showResults: boolean }; type HelpItem = { @@ -365,34 +378,38 @@ class DocumentationSearch extends React.Component { if (!algolia.enabled) return null; return ( - + {!this.props.hideMinimizeBtn && ( + + )} -
- - -
- + {!this.props.hideSearch && ( +
+ + +
+ )} + {this.state.showResults ? ( ) : ( diff --git a/app/client/src/components/designSystems/appsmith/help/HelpModal.tsx b/app/client/src/components/designSystems/appsmith/help/HelpModal.tsx index a12b46207b..51b04fff1c 100644 --- a/app/client/src/components/designSystems/appsmith/help/HelpModal.tsx +++ b/app/client/src/components/designSystems/appsmith/help/HelpModal.tsx @@ -16,6 +16,7 @@ import { AppState } from "reducers"; import { getCurrentUser } from "selectors/usersSelectors"; import { User } from "constants/userConstants"; import AnalyticsUtil from "utils/AnalyticsUtil"; +import { HELP_MODAL_HEIGHT, HELP_MODAL_WIDTH } from "constants/HelpConstants"; const { algolia, cloudHosting, intercomAppID } = getAppsmithConfigs(); const HelpButton = styled.button<{ @@ -47,8 +48,8 @@ const HelpButton = styled.button<{ } `; -const MODAL_WIDTH = 240; -const MODAL_HEIGHT = 206; +const MODAL_WIDTH = HELP_MODAL_WIDTH; +const MODAL_HEIGHT = HELP_MODAL_HEIGHT; const MODAL_BOTTOM_DISTANCE = 100; const MODAL_RIGHT_DISTANCE = 27; diff --git a/app/client/src/components/editorComponents/GlobalSearch/ActionLink.tsx b/app/client/src/components/editorComponents/GlobalSearch/ActionLink.tsx new file mode 100644 index 0000000000..8141aa1572 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/ActionLink.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import Icon, { IconSize } from "components/ads/Icon"; +import { Theme } from "constants/DefaultTheme"; +import { useContext } from "react"; +import styled, { withTheme } from "styled-components"; +import SearchContext from "./GlobalSearchContext"; +import { SearchItem } from "./utils"; + +export const StyledActionLink = styled.span<{ isActiveItem?: boolean }>` + visibility: ${(props) => (props.isActiveItem ? "visible" : "hidden")}; + display: inline-flex; +`; + +export const ActionLink = withTheme( + ({ + item, + theme, + isActiveItem, + }: { + item: SearchItem; + theme: Theme; + isActiveItem?: boolean; + }) => { + const searchContext = useContext(SearchContext); + return ( + + { + e.stopPropagation(); // to prevent toggleModal getting called twice + searchContext?.handleItemLinkClick(item, "SEARCH_ITEM_ICON_CLICK"); + }} + /> + + ); + }, +); + +export default ActionLink; diff --git a/app/client/src/components/editorComponents/GlobalSearch/AlgoliaSearchWrapper.tsx b/app/client/src/components/editorComponents/GlobalSearch/AlgoliaSearchWrapper.tsx new file mode 100644 index 0000000000..76f9efa7cb --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/AlgoliaSearchWrapper.tsx @@ -0,0 +1,37 @@ +import React, { useState, useCallback, useEffect } from "react"; +import algoliasearch from "algoliasearch/lite"; +import { InstantSearch } from "react-instantsearch-dom"; +import { getAppsmithConfigs } from "configs"; +import { debounce } from "lodash"; + +const { algolia } = getAppsmithConfigs(); +const searchClient = algoliasearch(algolia.apiId, algolia.apiKey); + +type SearchProps = { + query: string; + children: React.ReactNode; +}; + +const Search = ({ query, children }: SearchProps) => { + const [queryInState, setQueryInState] = useState(query); + const debouncedSetQueryInState = useCallback( + debounce(setQueryInState, 100), + [], + ); + + useEffect(() => { + debouncedSetQueryInState(query); + }, [query]); + + return ( + + {children} + + ); +}; + +export default Search; diff --git a/app/client/src/components/editorComponents/GlobalSearch/Description.tsx b/app/client/src/components/editorComponents/GlobalSearch/Description.tsx new file mode 100644 index 0000000000..937d8fef0b --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/Description.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect } from "react"; +import styled from "styled-components"; +import ActionLink from "./ActionLink"; +import Highlight from "./Highlight"; +import { getItemTitle, SEARCH_ITEM_TYPES } from "./utils"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import { SearchItem } from "./utils"; +import parseDocumentationContent from "./parseDocumentationContent"; + +type Props = { + activeItem: SearchItem; + activeItemType?: SEARCH_ITEM_TYPES; + query: string; + scrollPositionRef: React.MutableRefObject; +}; + +const Container = styled.div` + flex: 1; + display: flex; + flex-direction: column; + padding: ${(props) => + `${props.theme.spaces[5]}px ${props.theme.spaces[7]}px 0`}; + color: ${(props) => props.theme.colors.globalSearch.searchItemText}; + overflow: auto; + + ${(props) => getTypographyByKey(props, "spacedOutP1")}; + [class^="ais-"] { + ${(props) => getTypographyByKey(props, "spacedOutP1")}; + } + + img { + max-width: 100%; + } + + h1 { + ${(props) => getTypographyByKey(props, "largeH1")}; + word-break: break-word; + } + + h1, + h2, + h3, + strong { + color: #fff; + } + + .documentation-cta { + ${(props) => getTypographyByKey(props, "p3")} + white-space: nowrap; + background: ${(props) => + props.theme.colors.globalSearch.documentationCtaBackground}; + color: ${(props) => props.theme.colors.globalSearch.documentationCtaText}; + padding: ${(props) => props.theme.spaces[2]}px; + margin: 0 ${(props) => props.theme.spaces[2]}px; + position: relative; + bottom: 3px; + } + + & a { + color: ${(props) => props.theme.colors.globalSearch.documentLink}; + } + + code { + word-break: break-word; + background: ${(props) => props.theme.colors.globalSearch.codeBackground}; + padding: ${(props) => props.theme.spaces[2]}px; + } + + pre { + background: ${(props) => props.theme.colors.globalSearch.codeBackground}; + white-space: pre-wrap; + overflow: hidden; + padding: ${(props) => props.theme.spaces[6]}px; + } +`; + +const DocumentationDescription = ({ item }: { item: SearchItem }) => { + try { + const { + _highlightResult: { + document: { value: rawDocument }, + title: { value: rawTitle }, + }, + } = item; + const content = parseDocumentationContent({ + rawDocument: rawDocument, + rawTitle: rawTitle, + path: item.path, + }); + + return content ? ( +
+ ) : null; + } catch (e) { + return null; + } +}; + +const StyledHitEnterMessageContainer = styled.div` + background: ${(props) => + props.theme.colors.globalSearch.navigateUsingEnterSection}; + padding: ${(props) => + `${props.theme.spaces[6]}px ${props.theme.spaces[3]}px`}; + ${(props) => getTypographyByKey(props, "p3")} +`; + +const StyledKey = styled.span` + margin: 0 ${(props) => props.theme.spaces[1]}px; + color: ${(props) => props.theme.colors.globalSearch.navigateToEntityEnterkey}; + font-weight: bold; +`; + +const StyledHighlightWrapper = styled.span` + margin: 0 ${(props) => props.theme.spaces[1]}px; +`; + +const HitEnterMessage = ({ + item, + query, +}: { + item: SearchItem; + query: string; +}) => { + const title = getItemTitle(item); + + return ( + + ✨ Press to navigate to + + + + + + ); +}; + +const descriptionByType = { + [SEARCH_ITEM_TYPES.document]: DocumentationDescription, + [SEARCH_ITEM_TYPES.action]: HitEnterMessage, + [SEARCH_ITEM_TYPES.widget]: HitEnterMessage, + [SEARCH_ITEM_TYPES.datasource]: HitEnterMessage, + [SEARCH_ITEM_TYPES.page]: HitEnterMessage, + [SEARCH_ITEM_TYPES.sectionTitle]: () => null, +}; + +const Description = (props: Props) => { + const { activeItem, activeItemType } = props; + const containerRef = React.useRef(null); + + const onScroll = useCallback((e: React.UIEvent) => { + if ( + props.scrollPositionRef?.current || + props.scrollPositionRef?.current === 0 + ) { + props.scrollPositionRef.current = (e.target as HTMLDivElement).scrollTop; + } + }, []); + + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = props.scrollPositionRef?.current; + } + }, [containerRef.current, activeItem]); + + if (!activeItemType || !activeItem) return null; + const Component = descriptionByType[activeItemType]; + + return ( + + + + ); +}; + +export default Description; diff --git a/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchContext.tsx b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchContext.tsx new file mode 100644 index 0000000000..4520e7e7a7 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchContext.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { SearchItem } from "./utils"; + +type SearchContextType = { + handleItemLinkClick: (item?: SearchItem, source?: string) => void; + setActiveItemIndex: (index: number) => void; + activeItemIndex: number; +}; + +const SearchContext = React.createContext( + undefined, +); + +export default SearchContext; diff --git a/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHotKeys.tsx b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHotKeys.tsx new file mode 100644 index 0000000000..2091bae430 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHotKeys.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { HotkeysTarget } from "@blueprintjs/core/lib/esnext/components/hotkeys/hotkeysTarget.js"; +import { Hotkey, Hotkeys } from "@blueprintjs/core"; +import { SearchItem } from "./utils"; + +type Props = { + modalOpen: boolean; + toggleShow: () => void; + handleUpKey: () => void; + handleDownKey: () => void; + handleItemLinkClick: (item?: SearchItem, source?: string) => void; + children: React.ReactNode; +}; +@HotkeysTarget +class GlobalSearchHotKeys extends React.Component { + get hotKeysConfig() { + return [ + { + combo: "up", + onKeyDown: this.props.handleUpKey, + hideWhenModalClosed: true, + allowInInput: true, + group: "Omnibar", + label: "Move up the list", + }, + { + combo: "down", + onKeyDown: this.props.handleDownKey, + hideWhenModalClosed: true, + allowInInput: true, + group: "Omnibar", + label: "Move down the list", + }, + { + combo: "return", + onKeyDown: () => { + const activeElement = document.activeElement as any; + activeElement?.blur(); // scroll into view doesn't work with the search input focused + this.props.handleItemLinkClick(null, "ENTER_KEY"); + }, + hideWhenModalClosed: true, + allowInInput: true, + group: "Omnibar", + label: "Navigate", + }, + ].filter( + ({ hideWhenModalClosed }) => + !hideWhenModalClosed || (hideWhenModalClosed && this.props.modalOpen), + ); + } + + renderHotkeys() { + return ( + + {this.hotKeysConfig.map( + ({ combo, onKeyDown, allowInInput, label, group }, index) => ( + + ), + )} + + ); + } + + render() { + return
{this.props.children}
; + } +} + +export default GlobalSearchHotKeys; diff --git a/app/client/src/components/editorComponents/GlobalSearch/HelpBar.tsx b/app/client/src/components/editorComponents/GlobalSearch/HelpBar.tsx new file mode 100644 index 0000000000..e824ecedc1 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/HelpBar.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { connect } from "react-redux"; +import styled from "styled-components"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import Text, { TextType } from "components/ads/Text"; +import { toggleShowGlobalSearchModal } from "actions/globalSearchActions"; +import { HELPBAR_PLACEHOLDER } from "constants/messages"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import { isMac } from "utils/helpers"; + +const StyledHelpBar = styled.div` + padding: 0 ${(props) => props.theme.spaces[4]}px; + .placeholder-text { + ${(props) => getTypographyByKey(props, "p2")} + } + display: flex; + justify-content: space-between; + align-items: center; + color: ${(props) => props.theme.colors.globalSearch.helpBarText}; + background: ${(props) => props.theme.colors.globalSearch.helpBarBackground}; + height: 28px; + flex: 1; + max-width: 350px; +`; + +const modText = () => (isMac() ? : "ctrl"); +const comboText = <>{modText()} + K; + +type Props = { + toggleShowModal: () => void; +}; + +const HelpBar = ({ toggleShowModal }: Props) => { + return ( + + {HELPBAR_PLACEHOLDER} + + {comboText} + + + ); +}; + +const mapDispatchToProps = (dispatch: any) => ({ + toggleShowModal: () => { + AnalyticsUtil.logEvent("OPEN_OMNIBAR", { source: "NAVBAR_CLICK" }); + dispatch(toggleShowGlobalSearchModal()); + }, +}); + +export default connect(null, mapDispatchToProps)(HelpBar); diff --git a/app/client/src/components/editorComponents/GlobalSearch/Highlight.tsx b/app/client/src/components/editorComponents/GlobalSearch/Highlight.tsx new file mode 100644 index 0000000000..e62f3a1f39 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/Highlight.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +const Highlight = ({ match, text }: { match: string; text: string }) => { + if (!match) return {text}; + + const regEx = new RegExp(match, "ig"); + const parts = text?.split(regEx); + if (parts?.length === 1) return {text}; + let lastIndex = 0; + + return ( + + {parts?.map((part, index) => { + lastIndex += Math.max(part.length, 0); + const result = ( + + {part} + {index !== parts.length - 1 && ( + + {text.slice(lastIndex, lastIndex + match.length)} + + )} + + ); + lastIndex += match.length; + return result; + })} + + ); +}; + +export default Highlight; diff --git a/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx b/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx new file mode 100644 index 0000000000..70fc40e63f --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import styled from "styled-components"; +import NoSearchDataImage from "assets/images/no_search_data.png"; +import { NO_SEARCH_DATA_TEXT } from "constants/messages"; +import { getTypographyByKey } from "constants/DefaultTheme"; + +const Container = styled.div` + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + + ${(props) => getTypographyByKey(props, "spacedOutP1")} + color: ${(props) => props.theme.colors.globalSearch.emptyStateText}; + + .no-data-title { + margin-top: ${(props) => props.theme.spaces[3]}px; + } +`; + +const ResultsNotFound = () => ( + + No data +
{NO_SEARCH_DATA_TEXT}
+
+); + +export default ResultsNotFound; diff --git a/app/client/src/components/editorComponents/GlobalSearch/SearchBox.tsx b/app/client/src/components/editorComponents/GlobalSearch/SearchBox.tsx new file mode 100644 index 0000000000..5c5584b6d2 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/SearchBox.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import styled from "styled-components"; +import { connectSearchBox } from "react-instantsearch-dom"; +import { SearchBoxProvided } from "react-instantsearch-core"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import Icon from "components/ads/Icon"; +import { AppState } from "reducers"; +import { OMNIBAR_PLACEHOLDER } from "constants/messages"; + +const Container = styled.div` + padding: ${(props) => `0 ${props.theme.spaces[11]}px`}; + & input { + ${(props) => getTypographyByKey(props, "cardSubheader")} + background: transparent; + color: ${(props) => props.theme.colors.globalSearch.searchInputText}; + border: none; + padding: ${(props) => `${props.theme.spaces[7]}px 0`}; + flex: 1; + } +`; + +const InputContainer = styled.div` + display: flex; +`; + +const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === 38 || e.key === "ArrowUp") { + e.preventDefault(); + } +}; + +type SearchBoxProps = SearchBoxProvided & { + query: string; + setQuery: (query: string) => void; +}; + +const useListenToChange = (modalOpen: boolean) => { + const [listenToChange, setListenToChange] = useState(false); + + useEffect(() => { + setListenToChange(false); + let timer: number; + if (modalOpen) { + timer = setTimeout(() => setListenToChange(true), 100); + } + return () => clearTimeout(timer); + }, [modalOpen]); + + return listenToChange; +}; + +const SearchBox = ({ query, setQuery }: SearchBoxProps) => { + const { modalOpen } = useSelector((state: AppState) => state.ui.globalSearch); + const listenToChange = useListenToChange(modalOpen); + + const updateSearchQuery = useCallback( + (query) => { + // to prevent key combo to open modal from trigging query update + if (!listenToChange) return; + setQuery(query); + }, + [listenToChange], + ); + + return ( + + + updateSearchQuery(e.currentTarget.value)} + autoFocus + onKeyDown={handleKeyDown} + placeholder={OMNIBAR_PLACEHOLDER} + className="t--global-search-input" + /> + {query && ( + updateSearchQuery("")} + /> + )} + + + ); +}; + +export default connectSearchBox(SearchBox); diff --git a/app/client/src/components/editorComponents/GlobalSearch/SearchModal.tsx b/app/client/src/components/editorComponents/GlobalSearch/SearchModal.tsx new file mode 100644 index 0000000000..b314064464 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/SearchModal.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import styled from "styled-components"; +import { Overlay, Classes } from "@blueprintjs/core"; +import AnalyticsUtil from "utils/AnalyticsUtil"; + +const StyledDocsSearchModal = styled.div` + & { + .${Classes.OVERLAY} { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + .${Classes.OVERLAY_CONTENT} { + overflow: hidden; + top: 10vh; + } + } + } +`; + +type Props = { + modalOpen: boolean; + toggleShow: () => void; + children: React.ReactNode; +}; + +const DocsSearchModal = ({ modalOpen, toggleShow, children }: Props) => ( + + { + AnalyticsUtil.logEvent("CLOSE_OMNIBAR"); + }} + transitionDuration={25} + > +
+ {children} +
+
+
+); + +export default DocsSearchModal; diff --git a/app/client/src/components/editorComponents/GlobalSearch/SearchResults.tsx b/app/client/src/components/editorComponents/GlobalSearch/SearchResults.tsx new file mode 100644 index 0000000000..22088d0f3e --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/SearchResults.tsx @@ -0,0 +1,322 @@ +import React, { useEffect, useRef, useContext, useMemo } from "react"; +import { useSelector } from "react-redux"; +import { Highlight as AlgoliaHighlight } from "react-instantsearch-dom"; +import { Hit as IHit } from "react-instantsearch-core"; +import styled from "styled-components"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import Highlight from "./Highlight"; +import ActionLink, { StyledActionLink } from "./ActionLink"; +import scrollIntoView from "scroll-into-view-if-needed"; +import { + getItemType, + getItemTitle, + SEARCH_ITEM_TYPES, + SearchItem, +} from "./utils"; +import SearchContext from "./GlobalSearchContext"; +import { + getWidgetIcon, + getPluginIcon, + homePageIcon, + pageIcon, +} from "pages/Editor/Explorer/ExplorerIcons"; +import { HelpIcons } from "icons/HelpIcons"; +import { getActionConfig } from "pages/Editor/Explorer/Actions/helpers"; +import { AppState } from "reducers"; +import { keyBy, noop } from "lodash"; +import { getPageList } from "selectors/editorSelectors"; + +const DocumentIcon = HelpIcons.DOCUMENT; + +export const SearchItemContainer = styled.div<{ + isActiveItem: boolean; + itemType: SEARCH_ITEM_TYPES; +}>` + cursor: ${(props) => + props.itemType !== SEARCH_ITEM_TYPES.sectionTitle ? "pointer" : "default"}; + display: flex; + align-items: center; + padding: ${(props) => + `${props.theme.spaces[4]}px ${props.theme.spaces[4]}px`}; + color: ${(props) => props.theme.colors.globalSearch.searchItemText}; + margin: ${(props) => props.theme.spaces[1]}px 0; + background-color: ${(props) => + props.isActiveItem && props.itemType !== SEARCH_ITEM_TYPES.sectionTitle + ? props.theme.colors.globalSearch.activeSearchItemBackground + : "unset"}; + + &:hover { + background-color: ${(props) => + props.itemType !== SEARCH_ITEM_TYPES.sectionTitle + ? props.theme.colors.globalSearch.activeSearchItemBackground + : "unset"}; + ${StyledActionLink} { + visibility: visible; + } + } + + ${(props) => getTypographyByKey(props, "p3")}; + [class^="ais-"] { + ${(props) => getTypographyByKey(props, "p3")}; + } +`; + +const ItemTitle = styled.div` + margin-left: ${(props) => props.theme.spaces[5]}px; + display: flex; + justify-content: space-between; + flex: 1; + align-items: center; + ${(props) => getTypographyByKey(props, "p3")}; + font-w [class^="ais-"] { + ${(props) => getTypographyByKey(props, "p3")}; + } +`; + +const StyledDocumentIcon = styled(DocumentIcon)` + svg { + width: 14px; + height: 14px; + path { + fill: transparent; + } + } + display: flex; +`; + +const DocumentationItem = (props: { + item: SearchItem; + isActiveItem: boolean; +}) => { + return ( + <> + + + + + + + + + ); +}; + +const WidgetIconWrapper = styled.span` + svg { + height: 14px; + } + display: flex; +`; + +const usePageName = (pageId: string) => { + const pages = useSelector(getPageList); + const page = pages.find((page) => page.pageId === pageId); + return page?.pageName; +}; + +const WidgetItem = (props: { + query: string; + item: SearchItem; + isActiveItem: boolean; +}) => { + const { query, item } = props; + const { type } = item || {}; + let title = getItemTitle(item); + const pageName = usePageName(item.pageId); + title = `${pageName} / ${title}`; + + return ( + <> + {getWidgetIcon(type)} + + + + + + ); +}; + +const ActionIconWrapper = styled.div` + & > div { + display: flex; + align-items: center; + } +`; + +const ActionItem = (props: { + query: string; + item: SearchItem; + isActiveItem: boolean; +}) => { + const { item, query } = props; + const { config } = item || {}; + const { pluginType } = config; + const plugins = useSelector((state: AppState) => { + return state.entities.plugins.list; + }); + const pluginGroups = useMemo(() => keyBy(plugins, "id"), [plugins]); + const icon = getActionConfig(pluginType)?.getIcon( + item.config, + pluginGroups[item.config.datasource.pluginId], + ); + + let title = getItemTitle(item); + const pageName = usePageName(config.pageId); + title = `${pageName} / ${title}`; + + return ( + <> + {icon} + + + + + + ); +}; + +const DatasourceItem = (props: { + query: string; + item: SearchItem; + isActiveItem: boolean; +}) => { + const { item, query } = props; + const plugins = useSelector((state: AppState) => { + return state.entities.plugins.list; + }); + const pluginGroups = useMemo(() => keyBy(plugins, "id"), [plugins]); + const icon = getPluginIcon(pluginGroups[item.pluginId]); + const title = getItemTitle(item); + return ( + <> + {icon} + + + + + + ); +}; + +const PageItem = (props: { + query: string; + item: SearchItem; + isActiveItem: boolean; +}) => { + const { query, item } = props; + const title = getItemTitle(item); + const icon = item.isDefault ? homePageIcon : pageIcon; + + return ( + <> + {icon} + + + + + + ); +}; + +const StyledSectionTitleContainer = styled.div` + display: flex; + align-items: center; + & .section-title__icon { + width: 14px; + height: 14px; + margin-right: ${(props) => props.theme.spaces[5]}px; + } + & .section-title__text { + color: ${(props) => props.theme.colors.globalSearch.sectionTitle}; + } + margin-left: -${(props) => props.theme.spaces[3]}px; +`; + +const SectionTitle = ({ item }: { item: SearchItem }) => ( + + + {item.title} + +); + +const SearchItemByType = { + [SEARCH_ITEM_TYPES.document]: DocumentationItem, + [SEARCH_ITEM_TYPES.widget]: WidgetItem, + [SEARCH_ITEM_TYPES.action]: ActionItem, + [SEARCH_ITEM_TYPES.datasource]: DatasourceItem, + [SEARCH_ITEM_TYPES.page]: PageItem, + [SEARCH_ITEM_TYPES.sectionTitle]: SectionTitle, +}; + +type ItemProps = { + item: IHit | SearchItem; + index: number; + query: string; +}; + +const SearchItemComponent = (props: ItemProps) => { + const { item, index, query } = props; + const itemRef = useRef(null); + const searchContext = useContext(SearchContext); + const activeItemIndex = searchContext?.activeItemIndex; + const setActiveItemIndex = searchContext?.setActiveItemIndex || noop; + + const isActiveItem = activeItemIndex === index; + + useEffect(() => { + if (isActiveItem && itemRef.current) { + scrollIntoView(itemRef.current, { scrollMode: "if-needed" }); + } + }, [isActiveItem]); + + const itemType = getItemType(item); + const Item = SearchItemByType[itemType]; + + return ( + { + if (itemType !== SEARCH_ITEM_TYPES.sectionTitle) { + setActiveItemIndex(index); + if (itemType !== SEARCH_ITEM_TYPES.document) { + searchContext?.handleItemLinkClick(item, "SEARCH_ITEM"); + } + } + }} + className="t--docHit" + isActiveItem={isActiveItem} + itemType={itemType} + > + + + ); +}; + +const SearchResultsContainer = styled.div` + padding: 0 ${(props) => props.theme.spaces[6]}px; + overflow: auto; + width: 250px; +`; + +const SearchResults = ({ + searchResults, + query, +}: { + searchResults: SearchItem[]; + query: string; +}) => { + return ( + + {searchResults.map((item: SearchItem, index: number) => ( + + ))} + + ); +}; + +export default SearchResults; diff --git a/app/client/src/components/editorComponents/GlobalSearch/SetSearchResults.tsx b/app/client/src/components/editorComponents/GlobalSearch/SetSearchResults.tsx new file mode 100644 index 0000000000..17ce6f433c --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/SetSearchResults.tsx @@ -0,0 +1,28 @@ +import { useEffect, useCallback } from "react"; +import { connectHits } from "react-instantsearch-dom"; +import { Hit as IHit } from "react-instantsearch-core"; +import { debounce } from "lodash"; +import { DocSearchItem, SearchItem, SEARCH_ITEM_TYPES } from "./utils"; + +type Props = { + setDocumentationSearchResults: (item: DocSearchItem) => void; + hits: IHit[]; +}; + +const SearchResults = ({ hits, setDocumentationSearchResults }: Props) => { + const debounsedSetter = useCallback( + debounce(setDocumentationSearchResults, 100), + [], + ); + + useEffect(() => { + const filteredHits = hits.filter( + (doc: SearchItem) => doc.kind === SEARCH_ITEM_TYPES.document, + ); + debounsedSetter(filteredHits as any); + }, [hits]); + + return null; +}; + +export default connectHits(SearchResults); diff --git a/app/client/src/components/editorComponents/GlobalSearch/index.tsx b/app/client/src/components/editorComponents/GlobalSearch/index.tsx new file mode 100644 index 0000000000..2717bb2a1e --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/index.tsx @@ -0,0 +1,391 @@ +import React, { + useState, + useMemo, + useCallback, + useEffect, + useRef, +} from "react"; +import { useDispatch, useSelector } from "react-redux"; +import styled from "styled-components"; +import { useParams } from "react-router"; +import history from "utils/history"; +import { AppState } from "reducers"; +import SearchModal from "./SearchModal"; +import AlgoliaSearchWrapper from "./AlgoliaSearchWrapper"; +import SearchBox from "./SearchBox"; +import SearchResults from "./SearchResults"; +import SetSearchResults from "./SetSearchResults"; +import GlobalSearchHotKeys from "./GlobalSearchHotKeys"; +import SearchContext from "./GlobalSearchContext"; +import Description from "./Description"; +import ResultsNotFound from "./ResultsNotFound"; +import { getActions, getAllPageWidgets } from "selectors/entitiesSelector"; +import { useNavigateToWidget } from "pages/Editor/Explorer/Widgets/WidgetEntity"; +import { + toggleShowGlobalSearchModal, + setGlobalSearchQuery, +} from "actions/globalSearchActions"; +import { + getItemType, + SEARCH_ITEM_TYPES, + useDefaultDocumentationResults, + DocSearchItem, + SearchItem, + algoliaHighlightTag, + attachKind, +} from "./utils"; +import { getActionConfig } from "pages/Editor/Explorer/Actions/helpers"; +import { HelpBaseURL } from "constants/HelpConstants"; +import { ExplorerURLParams } from "pages/Editor/Explorer/helpers"; +import { BUILDER_PAGE_URL, DATA_SOURCES_EDITOR_ID_URL } from "constants/routes"; +import { getSelectedWidget } from "selectors/ui"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import { getPageList } from "selectors/editorSelectors"; +import useRecentEntities from "./useRecentEntities"; +import { keyBy, noop } from "lodash"; +import EntitiesIcon from "assets/icons/ads/entities.svg"; +import DocsIcon from "assets/icons/ads/docs.svg"; +import RecentIcon from "assets/icons/ads/recent.svg"; + +const StyledContainer = styled.div` + width: 750px; + height: 45vh; + background: ${(props) => props.theme.colors.globalSearch.containerBackground}; + box-shadow: ${(props) => props.theme.colors.globalSearch.containerShadow}; + display: flex; + flex-direction: column; + & .main { + display: flex; + flex: 1; + overflow: hidden; + background-color: #383838; + } + ${algoliaHighlightTag}, + & .ais-Highlight-highlighted, + & .search-highlighted { + background: unset; + color: ${(props) => props.theme.colors.globalSearch.searchItemHighlight}; + font-style: normal; + text-decoration: underline; + text-decoration-color: ${(props) => + props.theme.colors.globalSearch.highlightedTextUnderline}; + } +`; + +const Separator = styled.div` + margin: ${(props) => props.theme.spaces[3]}px 0; + width: 1px; + background-color: ${(props) => props.theme.colors.globalSearch.separator}; +`; + +const isModalOpenSelector = (state: AppState) => + state.ui.globalSearch.modalOpen; + +const searchQuerySelector = (state: AppState) => state.ui.globalSearch.query; + +const isMatching = (text = "", query = "") => + text?.toLowerCase().indexOf(query?.toLowerCase()) > -1; + +const getSectionTitle = (title: string, icon: any) => ({ + kind: SEARCH_ITEM_TYPES.sectionTitle, + title, + icon, +}); + +const GlobalSearch = () => { + const defaultDocs = useDefaultDocumentationResults(); + const params = useParams(); + const dispatch = useDispatch(); + const toggleShow = () => dispatch(toggleShowGlobalSearchModal()); + const [query, setQueryInState] = useState(""); + const setQuery = useCallback((query: string) => { + setQueryInState(query); + }, []); + const scrollPositionRef = useRef(0); + + const [ + documentationSearchResults, + setDocumentationSearchResultsInState, + ] = useState>([]); + + const setDocumentationSearchResults = useCallback((res) => { + setDocumentationSearchResultsInState(res); + }, []); + + const [activeItemIndex, setActiveItemIndexInState] = useState(1); + const setActiveItemIndex = useCallback((index) => { + scrollPositionRef.current = 0; + setActiveItemIndexInState(index); + }, []); + + const allWidgets = useSelector(getAllPageWidgets); + + const searchableWidgets = useMemo( + () => + allWidgets.filter( + (widget: any) => + ["CANVAS_WIDGET", "ICON_WIDGET"].indexOf(widget.type) === -1, + ), + [allWidgets], + ); + const actions = useSelector(getActions); + const modalOpen = useSelector(isModalOpenSelector); + const pages = useSelector(getPageList) || []; + const pageMap = keyBy(pages, "pageId"); + + const reducerDatasources = useSelector((state: AppState) => { + return state.entities.datasources.list; + }); + const datasourcesList = useMemo(() => { + return reducerDatasources.map((datasource) => ({ + ...datasource, + pageId: params?.pageId, + })); + }, [reducerDatasources]); + + const filteredDatasources = useMemo(() => { + if (!query) return datasourcesList; + return datasourcesList.filter((datasource) => + isMatching(datasource.name, query), + ); + }, [reducerDatasources, query]); + const recentEntities = useRecentEntities(); + + const resetSearchQuery = useSelector(searchQuerySelector); + const selectedWidgetId = useSelector(getSelectedWidget); + + // keeping query in component state until we can figure out fixed for the perf issues + // this is used to update query from outside the component, for ex. using the help button within prop. pane + useEffect(() => { + if (modalOpen && resetSearchQuery) { + setQuery(resetSearchQuery); + } else { + dispatch(setGlobalSearchQuery("")); + if (!query) setActiveItemIndex(1); + } + }, [modalOpen]); + + useEffect(() => { + setActiveItemIndex(1); + }, [query]); + + const filteredWidgets = useMemo(() => { + if (!query) return searchableWidgets; + + return searchableWidgets.filter((widget: any) => { + const page = pageMap[widget.pageId]; + const isPageNameMatching = isMatching(page?.pageName, query); + const isWidgetNameMatching = isMatching(widget?.widgetName, query); + + return isWidgetNameMatching || isPageNameMatching; + }); + }, [allWidgets, query]); + const filteredActions = useMemo(() => { + if (!query) return actions; + + return actions.filter((action: any) => { + const page = pageMap[action?.config?.pageId]; + const isPageNameMatching = isMatching(page?.pageName, query); + const isActionNameMatching = isMatching(action?.config?.name, query); + + return isActionNameMatching || isPageNameMatching; + }); + }, [actions, query]); + const filteredPages = useMemo(() => { + if (!query) return attachKind(pages, SEARCH_ITEM_TYPES.page); + + return attachKind( + pages.filter( + (page: any) => + page.pageName.toLowerCase().indexOf(query?.toLowerCase()) > -1, + ), + SEARCH_ITEM_TYPES.page, + ); + }, [pages, query]); + + const recentsSectionTitle = getSectionTitle("Recents", RecentIcon); + const docsSectionTitle = getSectionTitle("Documentation Links", DocsIcon); + const entitiesSectionTitle = getSectionTitle("Entities", EntitiesIcon); + + const searchResults = useMemo(() => { + if (!query) { + return [ + recentsSectionTitle, + ...recentEntities, + docsSectionTitle, + ...defaultDocs, + ]; + } + + const results = []; + + const entities = [ + entitiesSectionTitle, + ...filteredPages, + ...filteredWidgets, + ...filteredActions, + ...filteredDatasources, + ]; + + if (entities.length > 1) { + results.push(...entities); + } + + if (documentationSearchResults.length > 0) { + results.push(docsSectionTitle, ...documentationSearchResults); + } + + return results; + }, [ + filteredWidgets, + filteredActions, + documentationSearchResults, + filteredDatasources, + query, + recentEntities, + ]); + + const activeItem = useMemo(() => { + return searchResults[activeItemIndex] || {}; + }, [searchResults, activeItemIndex]); + + const getNextActiveItem = (nextIndex: number) => { + const max = Math.max(searchResults.length - 1, 0); + if (nextIndex < 0) return max; + else if (nextIndex > max) return 0; + else return nextIndex; + }; + + const handleUpKey = () => { + let nextIndex = getNextActiveItem(activeItemIndex - 1); + const activeItem = searchResults[nextIndex]; + if (activeItem && activeItem?.kind === SEARCH_ITEM_TYPES.sectionTitle) { + nextIndex = getNextActiveItem(nextIndex - 1); + } + setActiveItemIndex(nextIndex); + }; + + const handleDownKey = () => { + let nextIndex = getNextActiveItem(activeItemIndex + 1); + const activeItem = searchResults[nextIndex]; + if (activeItem && activeItem?.kind === SEARCH_ITEM_TYPES.sectionTitle) { + nextIndex = getNextActiveItem(nextIndex + 1); + } + setActiveItemIndex(nextIndex); + }; + + const { navigateToWidget } = useNavigateToWidget(); + + const handleDocumentationItemClick = (item: SearchItem) => { + window.open(item.path.replace("master", HelpBaseURL), "_blank"); + }; + + const handleWidgetClick = (activeItem: SearchItem) => { + toggleShow(); + navigateToWidget( + activeItem.widgetId, + activeItem.type, + activeItem.pageId, + selectedWidgetId === activeItem.widgetId, + activeItem.parentModalId, + ); + }; + + const handleActionClick = (item: SearchItem) => { + const { config } = item; + const { pageId, pluginType, id } = config; + const actionConfig = getActionConfig(pluginType); + const url = actionConfig?.getURL(params.applicationId, pageId, id); + toggleShow(); + url && history.push(url); + }; + + const handleDatasourceClick = (item: SearchItem) => { + toggleShow(); + history.push( + DATA_SOURCES_EDITOR_ID_URL(params.applicationId, item.pageId, item.id), + ); + }; + + const handlePageClick = (item: SearchItem) => { + toggleShow(); + history.push(BUILDER_PAGE_URL(params.applicationId, item.pageId)); + }; + + const itemClickHandlerByType = { + [SEARCH_ITEM_TYPES.document]: handleDocumentationItemClick, + [SEARCH_ITEM_TYPES.widget]: handleWidgetClick, + [SEARCH_ITEM_TYPES.action]: handleActionClick, + [SEARCH_ITEM_TYPES.datasource]: handleDatasourceClick, + [SEARCH_ITEM_TYPES.page]: handlePageClick, + [SEARCH_ITEM_TYPES.sectionTitle]: noop, + }; + + const handleItemLinkClick = (itemArg?: SearchItem, source?: string) => { + const item = itemArg || activeItem; + const type = getItemType(item) as SEARCH_ITEM_TYPES; + + AnalyticsUtil.logEvent("NAVIGATE_TO_ENTITY_FROM_OMNIBAR", { + type, + source, + }); + + itemClickHandlerByType[type](item); + }; + + const searchContext = { + handleItemLinkClick, + setActiveItemIndex, + activeItemIndex, + }; + + const hotKeyProps = { + modalOpen, + toggleShow, + handleUpKey, + handleDownKey, + handleItemLinkClick, + }; + + const activeItemType = useMemo(() => { + return activeItem ? getItemType(activeItem) : undefined; + }, [activeItem]); + + return ( + + + + + + +
+ + {searchResults.length > 0 ? ( + <> + + + + + ) : ( + + )} +
+
+
+
+
+
+ ); +}; + +export default GlobalSearch; diff --git a/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.test.ts b/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.test.ts new file mode 100644 index 0000000000..bc7fa299e8 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.test.ts @@ -0,0 +1,44 @@ +// eslint-disable-next-line +import parseDocumentationContent from "./parseDocumentationContent"; + +const expectedResult = `

Security Open Documentation

Does Appsmith store my data?

+

No, Appsmith does not store any data returned from your API endpoints or DB queries. Appsmith only acts as a proxy layer. When you query your database/API endpoint, the Appsmith server only appends sensitive credentials before forwarding the request to your backend. The Appsmith server doesn't expose sensitive credentials to the browser because that can lead to security breaches. Such a routing ensures security of your systems and data.

+

Security measures within Appsmith

+

Appsmith applications are secure-by-default. The security measures implemented for Appsmith installations are:

+
    +
  • On Appsmith Cloud, all connections are encrypted with TLS. For self-hosted instances, we offer the capability to setup SSL certificates via LetsEncrypt during the installation process.
  • +
  • Encrypt all sensitive credentials such as database credentials with AES-256 encryption. Each self-hosted Appsmith instance is configured with unique salt and password values ensuring data-at-rest security.
  • +
  • Appsmith Cloud will only connect to your databases/API endpoints through whitelisted IPs: 18.223.74.85 & 3.131.104.27. This ensures that you only have to expose database access to specific IPs when using our cloud offering.
  • +
  • Appsmith Cloud is hosted in AWS data centers on servers that are SOC 1 and SOC 2 compliant. We also maintain data redundancy on our cloud instances via regular backups.
  • +
  • Internal access to Appsmith Cloud is controlled through 2-factor authentication system along with audit logs.
  • +
  • Maintain an open channel of communication with security researchers to allow them to report security vulnerabilities responsibly. If you notice a security vulnerability, please email security@appsmith.com and we'll resolve them ASAP.
  • +
`; + +const sampleTitleResponse = `Security`; + +const sampleDocumentResponse = `# Does Appsmith store my data? + +No, Appsmith does not store any data returned from your API endpoints or DB queries. Appsmith only acts as a proxy layer. When you query your database/API endpoint, the Appsmith server only appends sensitive credentials before forwarding the request to your backend. The Appsmith server doesn't expose sensitive credentials to the browser because that can lead to security breaches. Such a routing ensures security of your systems and data. + +# Security measures within Appsmith + +Appsmith applications are secure-by-default. The security measures implemented for Appsmith installations are: + +* On Appsmith Cloud, all connections are encrypted with TLS. For self-hosted instances, we offer the capability to setup SSL certificates via LetsEncrypt during the installation process. +* Encrypt all sensitive credentials such as database credentials with AES-256 encryption. Each self-hosted Appsmith instance is configured with unique salt and password values ensuring data-at-rest security. +* Appsmith Cloud will only connect to your databases/API endpoints through whitelisted IPs: 18.223.74.85 & 3.131.104.27. This ensures that you only have to expose database access to specific IPs when using our cloud offering. +* Appsmith Cloud is hosted in AWS data centers on servers that are SOC 1 and SOC 2 compliant. We also maintain data redundancy on our cloud instances via regular backups. +* Internal access to Appsmith Cloud is controlled through 2-factor authentication system along with audit logs. +* Maintain an open channel of communication with security researchers to allow them to report security vulnerabilities responsibly. If you notice a security vulnerability, please email [security@appsmith.com](mailto:security@appsmith.com) and we'll resolve them ASAP.`; + +describe("parseDocumentationContent", () => { + it("works as expected", () => { + const sampleItem = { + rawTitle: sampleTitleResponse, + rawDocument: sampleDocumentResponse, + path: "master/security", + }; + const result = parseDocumentationContent(sampleItem); + expect(result).toStrictEqual(expectedResult); + }); +}); diff --git a/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.ts b/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.ts new file mode 100644 index 0000000000..43e2dccf6c --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.ts @@ -0,0 +1,131 @@ +import marked from "marked"; +import { HelpBaseURL } from "constants/HelpConstants"; +import { algoliaHighlightTag } from "./utils"; + +/** + * @param {String} HTML representing a single element + * @return {Element} + */ +export const htmlToElement = (html: string) => { + const template = document.createElement("template"); + html = html.trim(); // Never return a text node of whitespace as the result + template.innerHTML = html; + return template.content.firstChild; +}; + +/** + * strip: + * gitbook plugin tags + */ +const strip = (text: string) => text.replace(/{% .*?%}/gm, ""); + +/** + * strip: description tag from the top + */ +const stripMarkdown = (text: string) => + text.replace(/---\n[description]([\S\s]*?)---/gm, ""); + +const getDocumentationCTA = (path: any) => { + const href = path.replace("master", HelpBaseURL); + const htmlString = `Open Documentation`; + return htmlToElement(htmlString); +}; + +/** + * Replace all H1s with H2s + * Check first child of body + * if exact match as title -> replace with h1 + * else prepend h1 + * Append open documentation button to title + */ +const updateDocumentDescriptionTitle = (documentObj: any, item: any) => { + const { rawTitle, path } = item; + + Array.from(documentObj.querySelectorAll("h1")).forEach((match: any) => { + match.outerHTML = `

${match.innerHTML}

`; + }); + + let firstChild = documentObj.querySelector("body") + ?.firstChild as HTMLElement | null; + + const matchesExactly = rawTitle === firstChild?.innerHTML; + + // additional space for word-break + if (matchesExactly && firstChild) { + firstChild.outerHTML = `

${firstChild?.innerHTML}

`; + } else { + const h = document.createElement("h1"); + h.innerHTML = `${rawTitle} `; + firstChild?.parentNode?.insertBefore(h, firstChild); + } + + firstChild = documentObj.querySelector("body") + ?.firstChild as HTMLElement | null; + + if (firstChild) { + // append documentation button after title: + const ctaElement = getDocumentationCTA(path) as Node; + firstChild.appendChild(ctaElement); + } +}; + +const replaceHintTagsWithCode = (text: string) => { + let result = text.replace(/{% hint .*?%}/, "```"); + result = result.replace(/{% endhint .*?%}/, "```"); + result = marked(result); + return result; +}; + +const parseDocumentationContent = (item: any): string | undefined => { + try { + const { rawDocument } = item; + let value = rawDocument; + if (!value) return; + + value = stripMarkdown(value); + value = replaceHintTagsWithCode(value); + + const parsedDocument = marked(value); + + const domparser = new DOMParser(); + const documentObj = domparser.parseFromString(parsedDocument, "text/html"); + + // remove algolia highlight within code sections + const aisTag = new RegExp( + `<${algoliaHighlightTag}>|</${algoliaHighlightTag}>`, + "g", + ); + Array.from(documentObj.querySelectorAll("code")).forEach((match) => { + match.innerHTML = match.innerHTML.replace(aisTag, ""); + }); + + // update link hrefs and target + const aisTagEncoded = new RegExp( + `%3C${algoliaHighlightTag}%3E|%3C/${algoliaHighlightTag}%3E`, + "g", + ); + + Array.from(documentObj.querySelectorAll("a")).forEach((match) => { + match.target = "_blank"; + try { + const hrefURL = new URL(match.href); + const isRelativeURL = hrefURL.hostname === window.location.hostname; + match.href = !isRelativeURL + ? match.href + : `${HelpBaseURL}/${match.getAttribute("href")}`; + match.href = match.href.replace(aisTagEncoded, ""); + } catch (e) {} + }); + + // update description title + updateDocumentDescriptionTitle(documentObj, item); + + const content = strip(documentObj.body.innerHTML).trim(); + return content; + } catch (e) { + console.log(e, "err"); + return; + } +}; + +export default parseDocumentationContent; diff --git a/app/client/src/components/editorComponents/GlobalSearch/useRecentEntities.tsx b/app/client/src/components/editorComponents/GlobalSearch/useRecentEntities.tsx new file mode 100644 index 0000000000..a2957bf538 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/useRecentEntities.tsx @@ -0,0 +1,55 @@ +import { useSelector } from "react-redux"; +import { AppState } from "reducers"; +import { getPageList } from "selectors/editorSelectors"; +import { getActions, getAllWidgetsMap } from "selectors/entitiesSelector"; +import { SEARCH_ITEM_TYPES } from "./utils"; +import { get } from "lodash"; + +const recentEntitiesSelector = (state: AppState) => + state.ui.globalSearch.recentEntities; + +const useResentEntities = () => { + const widgetsMap = useSelector(getAllWidgetsMap); + const recentEntities = useSelector(recentEntitiesSelector); + const actions = useSelector(getActions); + const reducerDatasources = useSelector((state: AppState) => { + return state.entities.datasources.list; + }); + + const pages = useSelector(getPageList) || []; + + const populatedRecentEntities = recentEntities + .map((entity) => { + const { type, id, params } = entity; + if (type === "page") { + const result = pages.find((page) => page.pageId === id); + if (result) { + return { + ...result, + kind: SEARCH_ITEM_TYPES.page, + }; + } else { + return null; + } + } else if (type === "datasource") { + const datasource = reducerDatasources.find( + (reducerDatasource) => reducerDatasource.id === id, + ); + return ( + datasource && { + ...datasource, + pageId: params?.pageId, + } + ); + } else if (type === "action") + return actions.find((action) => action?.config?.id === id); + else if (type === "widget") { + return get(widgetsMap, id, null); + } + }) + .filter(Boolean); + + return populatedRecentEntities; +}; + +export default useResentEntities; diff --git a/app/client/src/components/editorComponents/GlobalSearch/utils.tsx b/app/client/src/components/editorComponents/GlobalSearch/utils.tsx new file mode 100644 index 0000000000..0c4294bf74 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/utils.tsx @@ -0,0 +1,140 @@ +import { Datasource } from "entities/Datasource"; +import { useEffect, useState } from "react"; + +export type RecentEntity = { + type: string; + id: string; + params?: Record; +}; + +export enum SEARCH_ITEM_TYPES { + document = "document", + action = "action", + widget = "widget", + datasource = "datasource", + page = "page", + sectionTitle = "sectionTitle", +} + +export type DocSearchItem = { + document?: string; + title: string; + _highlightResult: { + document: { value: string }; + title: { value: string }; + }; + kind: string; + path: string; +}; + +export type SearchItem = DocSearchItem | Datasource | any; + +// todo better checks here? +export const getItemType = (item: SearchItem): SEARCH_ITEM_TYPES => { + let type: SEARCH_ITEM_TYPES; + if (item.widgetName) type = SEARCH_ITEM_TYPES.widget; + else if ( + item.kind === SEARCH_ITEM_TYPES.document || + item.kind === SEARCH_ITEM_TYPES.page || + item.kind === SEARCH_ITEM_TYPES.sectionTitle + ) + type = item.kind; + else if (item.kind === SEARCH_ITEM_TYPES.page) type = SEARCH_ITEM_TYPES.page; + else if (item.config?.name) type = SEARCH_ITEM_TYPES.action; + else type = SEARCH_ITEM_TYPES.datasource; + + return type; +}; + +export const getItemTitle = (item: SearchItem): string => { + const type = getItemType(item); + + switch (type) { + case SEARCH_ITEM_TYPES.action: + return item?.config?.name; + case SEARCH_ITEM_TYPES.widget: + return item?.widgetName; + case SEARCH_ITEM_TYPES.datasource: + return item?.name; + case SEARCH_ITEM_TYPES.page: + return item?.pageName; + case SEARCH_ITEM_TYPES.sectionTitle: + return item?.title; + default: + return ""; + } +}; + +const defaultDocsConfig = [ + { + link: + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/tutorial-1/README.md", + title: "Tutorial", + path: "master/tutorial-1", + kind: "document", + }, + { + link: + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/core-concepts/connecting-to-data-sources/README.md", + title: "Connecting to Data Sources", + path: "master/core-concepts/connecting-to-data-sources", + kind: "document", + }, + { + link: + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/core-concepts/displaying-data-read/README.md", + title: "Displaying Data (Read)", + path: "master/core-concepts/displaying-data-read", + kind: "document", + }, + { + link: + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/core-concepts/writing-code/README.md", + title: "Writing Code", + path: "master/core-concepts/writing-code", + kind: "document", + }, +]; + +const githubDocsAssetsPath = + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/.gitbook"; + +export const useDefaultDocumentationResults = () => { + const [defaultDocs, setDefaultDocs] = useState([]); + + useEffect(() => { + (async () => { + const data = await Promise.all( + defaultDocsConfig.map(async (doc: any) => { + const response = await fetch(doc.link); + let document = await response.text(); + const assetRegex = new RegExp("[../]*?/.gitbook", "g"); + document = document.replaceAll(assetRegex, githubDocsAssetsPath); + return { + _highlightResult: { + document: { + value: document, + }, + title: { + value: doc.title, + }, + }, + ...doc, + } as DocSearchItem; + }), + ); + setDefaultDocs(data); + })(); + }, []); + + return defaultDocs; +}; + +export const algoliaHighlightTag = "ais-highlight-0000000000"; + +export const attachKind = (source: any[], kind: string) => { + return source.map((s) => ({ + ...s, + kind, + })); +}; diff --git a/app/client/src/components/editorComponents/WidgetNameComponent/index.tsx b/app/client/src/components/editorComponents/WidgetNameComponent/index.tsx index 515d739166..eeb10ce381 100644 --- a/app/client/src/components/editorComponents/WidgetNameComponent/index.tsx +++ b/app/client/src/components/editorComponents/WidgetNameComponent/index.tsx @@ -10,7 +10,6 @@ import { } from "utils/hooks/dragResizeHooks"; import AnalyticsUtil from "utils/AnalyticsUtil"; import { WidgetType } from "constants/WidgetConstants"; -import HelpControl from "./HelpControl"; import PerformanceTracker, { PerformanceTransactionName, } from "utils/PerformanceTracker"; @@ -110,10 +109,6 @@ export const WidgetNameComponent = (props: WidgetNameComponentProps) => { return showWidgetName ? ( - ` scrollbar-color: ${(props) => props.theme.colors.paneText}; - scrollbar-width: thin; &::-webkit-scrollbar { width: 4px; diff --git a/app/client/src/constants/HelpConstants.ts b/app/client/src/constants/HelpConstants.ts index 43fe688253..70fd203670 100644 --- a/app/client/src/constants/HelpConstants.ts +++ b/app/client/src/constants/HelpConstants.ts @@ -110,3 +110,6 @@ export const HelpMap = { }; export const HelpBaseURL = "https://docs.appsmith.com"; + +export const HELP_MODAL_WIDTH = 240; +export const HELP_MODAL_HEIGHT = 206; diff --git a/app/client/src/constants/ReduxActionConstants.tsx b/app/client/src/constants/ReduxActionConstants.tsx index e9ea9bd582..b0755c8ecf 100644 --- a/app/client/src/constants/ReduxActionConstants.tsx +++ b/app/client/src/constants/ReduxActionConstants.tsx @@ -5,6 +5,9 @@ import { ERROR_CODES } from "constants/ApiConstants"; import { AppLayoutConfig } from "reducers/entityReducers/pageListReducer"; export const ReduxActionTypes: { [key: string]: string } = { + HANDLE_PATH_UPDATED: "HANDLE_PATH_UPDATED", + RESET_EDITOR_REQUEST: "RESET_EDITOR_REQUEST", + RESET_EDITOR_SUCCESS: "RESET_EDITOR_SUCCESS", INITIALIZE_EDITOR: "INITIALIZE_EDITOR", INITIALIZE_EDITOR_SUCCESS: "INITIALIZE_EDITOR_SUCCESS", REPORT_ERROR: "REPORT_ERROR", @@ -338,10 +341,17 @@ export const ReduxActionTypes: { [key: string]: string } = { CURRENT_APPLICATION_NAME_UPDATE: "CURRENT_APPLICATION_NAME_UPDATE", CURRENT_APPLICATION_LAYOUT_UPDATE: "CURRENT_APPLICATION_LAYOUT_UPDATE", SET_WIDGET_LOADING: "SET_WIDGET_LOADING", + SET_GLOBAL_SEARCH_QUERY: "SET_GLOBAL_SEARCH_QUERY", + TOGGLE_SHOW_GLOBAL_SEARCH_MODAL: "TOGGLE_SHOW_GLOBAL_SEARCH_MODAL", FETCH_RELEASES_SUCCESS: "FETCH_RELEASES_SUCCESS", RESET_UNREAD_RELEASES_COUNT: "RESET_UNREAD_RELEASES_COUNT", SET_LOADING_ENTITIES: "SET_LOADING_ENTITIES", RESET_CURRENT_APPLICATION: "RESET_CURRENT_APPLICATION", + UPDATE_RECENT_ENTITY: "UPDATE_RECENT_ENTITY", + RESTORE_RECENT_ENTITIES_REQUEST: "RESTORE_RECENT_ENTITIES_REQUEST", + RESTORE_RECENT_ENTITIES_SUCCESS: "RESTORE_RECENT_ENTITIES_SUCCESS", + SET_RECENT_ENTITIES: "SET_RECENT_ENTITIES", + RESET_RECENT_ENTITIES: "RESET_RECENT_ENTITIES", UPDATE_API_ACTION_BODY_CONTENT_TYPE: "UPDATE_API_ACTION_BODY_CONTENT_TYPE", }; diff --git a/app/client/src/constants/messages.ts b/app/client/src/constants/messages.ts index 5f95ddc1fb..457540a374 100644 --- a/app/client/src/constants/messages.ts +++ b/app/client/src/constants/messages.ts @@ -190,3 +190,7 @@ export const LOCAL_STORAGE_QUOTA_EXCEEDED_MESSAGE = "Error saving a key in localStorage. You have exceeded the allowed storage size limit"; export const LOCAL_STORAGE_NO_SPACE_LEFT_ON_DEVICE_MESSAGE = "Error saving a key in localStorage. You have run out of disk space"; + +export const OMNIBAR_PLACEHOLDER = "Search Widgets, Queries, Documentation"; +export const HELPBAR_PLACEHOLDER = "Quick search & navigation"; +export const NO_SEARCH_DATA_TEXT = "Search you must meaningful but"; diff --git a/app/client/src/constants/routes.ts b/app/client/src/constants/routes.ts index 8dcb2a6e61..9fe215b42f 100644 --- a/app/client/src/constants/routes.ts +++ b/app/client/src/constants/routes.ts @@ -1,3 +1,5 @@ +const { match } = require("path-to-regexp"); + export const BASE_URL = "/"; export const ORG_URL = "/org"; export const PAGE_NOT_FOUND_URL = "/404"; @@ -176,3 +178,8 @@ export const AUTH_LOGIN_URL = `${USER_AUTH_URL}/login`; export const ORG_INVITE_USERS_PAGE_URL = `${ORG_URL}/invite`; export const ORG_SETTINGS_PAGE_URL = `${ORG_URL}/settings`; + +export const matchApiPath = match(API_EDITOR_ID_URL()); +export const matchDatasourcePath = match(DATA_SOURCES_EDITOR_ID_URL()); +export const matchQueryPath = match(QUERIES_EDITOR_ID_URL()); +export const matchBuilderPath = match(BUILDER_URL); diff --git a/app/client/src/mockResponses/WidgetSidebarResponse.tsx b/app/client/src/mockResponses/WidgetSidebarResponse.tsx index 205bb38231..3290fa508f 100644 --- a/app/client/src/mockResponses/WidgetSidebarResponse.tsx +++ b/app/client/src/mockResponses/WidgetSidebarResponse.tsx @@ -1,5 +1,6 @@ import { WidgetCardProps } from "widgets/BaseWidget"; import { generateReactKey } from "utils/generators"; +import { keyBy } from "lodash"; /* eslint-disable no-useless-computed-key */ const WidgetSidebarResponse: WidgetCardProps[] = [ @@ -101,3 +102,5 @@ const WidgetSidebarResponse: WidgetCardProps[] = [ ]; export default WidgetSidebarResponse; + +export const widgetSidebarConfig = keyBy(WidgetSidebarResponse, "type"); diff --git a/app/client/src/pages/Editor/EditorHeader.tsx b/app/client/src/pages/Editor/EditorHeader.tsx index e26edf4172..2ec26f47cd 100644 --- a/app/client/src/pages/Editor/EditorHeader.tsx +++ b/app/client/src/pages/Editor/EditorHeader.tsx @@ -11,7 +11,6 @@ import { import AppInviteUsersForm from "pages/organization/AppInviteUsersForm"; import StyledHeader from "components/designSystems/appsmith/StyledHeader"; import AnalyticsUtil from "utils/AnalyticsUtil"; -import HelpModal from "components/designSystems/appsmith/help/HelpModal"; import { FormDialogComponent } from "components/editorComponents/form/FormDialogComponent"; import AppsmithLogo from "assets/images/appsmith_logo_square.png"; import { Link } from "react-router-dom"; @@ -38,6 +37,7 @@ import EditableAppName from "./EditableAppName"; import Boxed from "components/editorComponents/Onboarding/Boxed"; import OnboardingHelper from "components/editorComponents/Onboarding/Helper"; import { OnboardingStep } from "constants/OnboardingConstants"; +import GlobalSearch from "components/editorComponents/GlobalSearch"; import EndOnboardingTour from "components/editorComponents/Onboarding/EndTour"; import ProfileDropdown from "pages/common/ProfileDropdown"; import { getCurrentUser } from "selectors/usersSelectors"; @@ -46,6 +46,8 @@ import Button, { Size } from "components/ads/Button"; import { IconWrapper } from "components/ads/Icon"; import { Profile } from "pages/common/ProfileImage"; import { getTypographyByKey } from "constants/DefaultTheme"; +import HelpBar from "components/editorComponents/GlobalSearch/HelpBar"; +import HelpButton from "./HelpButton"; import OnboardingIndicator from "components/editorComponents/Onboarding/Indicator"; import { getThemeDetails, ThemeMode } from "selectors/themeSelectors"; @@ -81,7 +83,10 @@ const HeaderWrapper = styled(StyledHeader)` } `; +// looks offset by 1px even though, checking bounding rect values const HeaderSection = styled.div` + position: relative; + top: -1px; display: flex; flex: 1; overflow: hidden; @@ -90,6 +95,9 @@ const HeaderSection = styled.div` justify-content: flex-start; } :nth-child(2) { + justify-content: center; + } + :nth-child(3) { justify-content: flex-end; } `; @@ -231,6 +239,10 @@ export const EditorHeader = (props: EditorHeaderProps) => { )} + + + + @@ -294,8 +306,8 @@ export const EditorHeader = (props: EditorHeaderProps) => { )} - + ); diff --git a/app/client/src/pages/Editor/Explorer/Actions/helpers.tsx b/app/client/src/pages/Editor/Explorer/Actions/helpers.tsx index 0afb6ef6bb..665f7811bf 100644 --- a/app/client/src/pages/Editor/Explorer/Actions/helpers.tsx +++ b/app/client/src/pages/Editor/Explorer/Actions/helpers.tsx @@ -95,6 +95,12 @@ export const ACTION_PLUGIN_MAP: Array< } }); +export const getActionConfig = (type: PluginType) => + ACTION_PLUGIN_MAP.find( + (configByType: ActionGroupConfig | undefined) => + configByType?.type === type, + ); + export const getPluginGroups = ( page: Page, step: number, diff --git a/app/client/src/pages/Editor/Explorer/Widgets/WidgetEntity.tsx b/app/client/src/pages/Editor/Explorer/Widgets/WidgetEntity.tsx index 296a9d8fb2..9cc4474515 100644 --- a/app/client/src/pages/Editor/Explorer/Widgets/WidgetEntity.tsx +++ b/app/client/src/pages/Editor/Explorer/Widgets/WidgetEntity.tsx @@ -43,15 +43,42 @@ export const navigateToCanvas = ( } }; +export const useNavigateToWidget = () => { + const params = useParams(); + const dispatch = useDispatch(); + const { selectWidget } = useWidgetSelection(); + + const navigateToWidget = useCallback( + ( + widgetId: string, + widgetType: WidgetType, + pageId: string, + isWidgetSelected?: boolean, + parentModalId?: string, + ) => { + if (widgetType === WidgetTypes.MODAL_WIDGET) { + dispatch(showModal(widgetId)); + return; + } + if (parentModalId) dispatch(showModal(parentModalId)); + else dispatch(closeAllModals()); + navigateToCanvas(params, window.location.pathname, pageId, widgetId); + flashElementById(widgetId); + if (!isWidgetSelected) selectWidget(widgetId); + dispatch(forceOpenPropertyPane(widgetId)); + }, + [dispatch, params, selectWidget], + ); + + return { navigateToWidget }; +}; + const useWidget = ( widgetId: string, widgetType: WidgetType, pageId: string, parentModalId?: string, ) => { - const params = useParams(); - const dispatch = useDispatch(); - const { selectWidget } = useWidgetSelection(); const selectedWidget = useSelector( (state: AppState) => state.ui.widgetDragResize.selectedWidget, ); @@ -60,29 +87,21 @@ const useWidget = ( widgetId, ]); - const navigateToWidget = useCallback(() => { - if (widgetType === WidgetTypes.MODAL_WIDGET) { - dispatch(showModal(widgetId)); - return; - } - if (parentModalId) dispatch(showModal(parentModalId)); - else dispatch(closeAllModals()); - navigateToCanvas(params, window.location.pathname, pageId, widgetId); - flashElementById(widgetId); - if (!isWidgetSelected) selectWidget(widgetId); - dispatch(forceOpenPropertyPane(widgetId)); - }, [ - dispatch, - params, - selectWidget, - widgetType, - widgetId, - parentModalId, - pageId, - isWidgetSelected, - ]); + const { navigateToWidget } = useNavigateToWidget(); - return { navigateToWidget, isWidgetSelected }; + const boundNavigateToWidget = useCallback( + () => + navigateToWidget( + widgetId, + widgetType, + pageId, + isWidgetSelected, + parentModalId, + ), + [widgetId, widgetType, pageId, isWidgetSelected, parentModalId], + ); + + return { navigateToWidget: boundNavigateToWidget, isWidgetSelected }; }; export type WidgetEntityProps = { diff --git a/app/client/src/pages/Editor/GlobalHotKeys.tsx b/app/client/src/pages/Editor/GlobalHotKeys.tsx new file mode 100644 index 0000000000..6b5db918c7 --- /dev/null +++ b/app/client/src/pages/Editor/GlobalHotKeys.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import { connect } from "react-redux"; +import { AppState } from "reducers"; +import { Hotkey, Hotkeys } from "@blueprintjs/core"; +import { HotkeysTarget } from "@blueprintjs/core/lib/esnext/components/hotkeys/hotkeysTarget.js"; +import { + copyWidget, + cutWidget, + deleteSelectedWidget, + pasteWidget, +} from "actions/widgetActions"; +import { toggleShowGlobalSearchModal } from "actions/globalSearchActions"; +import { isMac } from "utils/helpers"; +import { getSelectedWidget } from "selectors/ui"; +import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; +import { getSelectedText } from "utils/helpers"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import { + ENTITY_EXPLORER_SEARCH_ID, + WIDGETS_SEARCH_ID, +} from "constants/Explorer"; + +type Props = { + copySelectedWidget: () => void; + pasteCopiedWidget: () => void; + deleteSelectedWidget: () => void; + cutSelectedWidget: () => void; + toggleShowGlobalSearchModal: () => void; + selectedWidget?: string; + children: React.ReactNode; +}; + +@HotkeysTarget +class GlobalHotKeys extends React.Component { + public stopPropagationIfWidgetSelected(e: KeyboardEvent): boolean { + if ( + this.props.selectedWidget && + this.props.selectedWidget != MAIN_CONTAINER_WIDGET_ID && + !getSelectedText() + ) { + e.preventDefault(); + e.stopPropagation(); + return true; + } + return false; + } + + public renderHotkeys() { + return ( + + { + const entitySearchInput = document.getElementById( + ENTITY_EXPLORER_SEARCH_ID, + ); + const widgetSearchInput = document.getElementById( + WIDGETS_SEARCH_ID, + ); + if (entitySearchInput) entitySearchInput.focus(); + if (widgetSearchInput) widgetSearchInput.focus(); + e.preventDefault(); + e.stopPropagation(); + }} + /> + { + console.log("toggleShowGlobalSearchModal"); + e.preventDefault(); + this.props.toggleShowGlobalSearchModal(); + AnalyticsUtil.logEvent("OPEN_OMNIBAR", { source: "HOTKEY_COMBO" }); + }} + allowInInput={false} + label="Show omnibar" + global={true} + /> + { + if (this.stopPropagationIfWidgetSelected(e)) { + this.props.copySelectedWidget(); + } + }} + /> + { + this.props.pasteCopiedWidget(); + }} + /> + { + if (this.stopPropagationIfWidgetSelected(e) && isMac()) { + this.props.deleteSelectedWidget(); + } + }} + /> + { + if (this.stopPropagationIfWidgetSelected(e)) { + this.props.deleteSelectedWidget(); + } + }} + /> + { + if (this.stopPropagationIfWidgetSelected(e)) { + this.props.cutSelectedWidget(); + } + }} + /> + + ); + } + + render() { + return
{this.props.children}
; + } +} + +const mapStateToProps = (state: AppState) => ({ + selectedWidget: getSelectedWidget(state), +}); + +const mapDispatchToProps = (dispatch: any) => { + return { + copySelectedWidget: () => dispatch(copyWidget(true)), + pasteCopiedWidget: () => dispatch(pasteWidget()), + deleteSelectedWidget: () => dispatch(deleteSelectedWidget(true)), + cutSelectedWidget: () => dispatch(cutWidget()), + toggleShowGlobalSearchModal: () => dispatch(toggleShowGlobalSearchModal()), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(GlobalHotKeys); diff --git a/app/client/src/pages/Editor/HelpButton.tsx b/app/client/src/pages/Editor/HelpButton.tsx new file mode 100644 index 0000000000..76f0127009 --- /dev/null +++ b/app/client/src/pages/Editor/HelpButton.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import styled, { createGlobalStyle } from "styled-components"; +import { Popover, Position } from "@blueprintjs/core"; + +import DocumentationSearch from "components/designSystems/appsmith/help/DocumentationSearch"; +import Icon, { IconSize } from "components/ads/Icon"; + +import { HELP_MODAL_WIDTH } from "constants/HelpConstants"; +import AnalyticsUtil from "utils/AnalyticsUtil"; + +const HelpPopoverStyle = createGlobalStyle` + .bp3-popover.bp3-minimal.navbar-help-popover { + margin-top: 0 !important; + } +`; + +const StyledTrigger = styled.div` + cursor: pointer; + width: 25px; + height: 25px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 ${(props) => props.theme.spaces[2]}px; + background: ${(props) => + props.theme.colors.globalSearch.helpButtonBackground}; +`; + +const Trigger = () => ( + + + +); + +const onOpened = () => { + AnalyticsUtil.logEvent("OPEN_HELP", { page: "Editor" }); +}; +const HelpButton = () => { + return ( + + <> + + + +
+ +
+
+ ); +}; + +export default HelpButton; diff --git a/app/client/src/pages/Editor/PropertyPaneHelpButton.tsx b/app/client/src/pages/Editor/PropertyPaneHelpButton.tsx new file mode 100644 index 0000000000..0fcd5b8fdf --- /dev/null +++ b/app/client/src/pages/Editor/PropertyPaneHelpButton.tsx @@ -0,0 +1,39 @@ +import React, { useCallback } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { withTheme } from "styled-components"; +import { Icon } from "@blueprintjs/core"; + +import { + setGlobalSearchQuery, + toggleShowGlobalSearchModal, +} from "actions/globalSearchActions"; +import { getSelectedWidget } from "sagas/selectors"; +import { Theme } from "constants/DefaultTheme"; +import { widgetSidebarConfig } from "mockResponses/WidgetSidebarResponse"; + +type Props = { + theme: Theme; +}; + +const PropertyPaneHelpButton = withTheme(({ theme }: Props) => { + const selectedWidget = useSelector(getSelectedWidget); + const selectedWidgetType = selectedWidget?.type; + const dispatch = useDispatch(); + const config = selectedWidgetType && widgetSidebarConfig[selectedWidgetType]; + + const openHelpModal = useCallback(() => { + dispatch(setGlobalSearchQuery(config?.widgetCardName || "")); + dispatch(toggleShowGlobalSearchModal()); + }, [selectedWidgetType]); + + return ( + + ); +}); + +export default PropertyPaneHelpButton; diff --git a/app/client/src/pages/Editor/PropertyPaneTitle.tsx b/app/client/src/pages/Editor/PropertyPaneTitle.tsx index 34c4e58610..9dff508a8f 100644 --- a/app/client/src/pages/Editor/PropertyPaneTitle.tsx +++ b/app/client/src/pages/Editor/PropertyPaneTitle.tsx @@ -11,7 +11,7 @@ import { getExistingWidgetNames } from "sagas/selectors"; import { removeSpecialChars } from "utils/helpers"; import { useToggleEditWidgetName } from "utils/hooks/dragResizeHooks"; import AnalyticsUtil from "utils/AnalyticsUtil"; -import { BindingText } from "pages/Editor/APIEditor/Form"; +import PropertyPaneHelpButton from "pages/Editor/PropertyPaneHelpButton"; import { Icon, Tooltip, Position, Classes } from "@blueprintjs/core"; import { WidgetType } from "constants/WidgetConstants"; @@ -19,6 +19,7 @@ import { theme } from "constants/DefaultTheme"; import { ControlIcons } from "icons/ControlIcons"; import { FormIcons } from "icons/FormIcons"; import { deleteSelectedWidget, copyWidget } from "actions/widgetActions"; + const CopyIcon = ControlIcons.COPY_CONTROL; const DeleteIcon = FormIcons.DELETE_ICON; const Wrapper = styled.div` @@ -151,18 +152,12 @@ const PropertyPaneTitle = memo((props: PropertyPaneTitleProps) => { /> - You can connect data from your API by adding - {`{{apiName.data}}`} - to a widget property -
- } + content={Explore widget related docs} position={Position.TOP} hoverOpenDelay={200} boundary="window" > - + void; - pasteCopiedWidget: () => void; - deleteSelectedWidget: () => void; - cutSelectedWidget: () => void; user?: User; selectedWidget?: string; lightTheme: Theme; + resetEditorRequest: () => void; + handlePathUpdated: (pathName: string) => void; }; type Props = EditorProps & RouteComponentProps; -const getSelectedText = () => { - if (typeof window.getSelection === "function") { - const selectionObj = window.getSelection(); - return selectionObj && selectionObj.toString(); - } -}; - -@HotkeysTarget class Editor extends Component { - public stopPropagationIfWidgetSelected(e: KeyboardEvent): boolean { - if ( - this.props.selectedWidget && - this.props.selectedWidget != MAIN_CONTAINER_WIDGET_ID && - !getSelectedText() - ) { - e.preventDefault(); - e.stopPropagation(); - return true; - } - return false; - } + unlisten: any; - public renderHotkeys() { - return ( - - { - const entitySearchInput = document.getElementById( - ENTITY_EXPLORER_SEARCH_ID, - ); - const widgetSearchInput = document.getElementById( - WIDGETS_SEARCH_ID, - ); - if (entitySearchInput) entitySearchInput.focus(); - if (widgetSearchInput) widgetSearchInput.focus(); - e.preventDefault(); - e.stopPropagation(); - }} - /> - { - if (this.stopPropagationIfWidgetSelected(e)) { - this.props.copySelectedWidget(); - } - }} - /> - { - this.props.pasteCopiedWidget(); - }} - /> - { - if (this.stopPropagationIfWidgetSelected(e) && isMac()) { - this.props.deleteSelectedWidget(); - } - }} - /> - { - if (this.stopPropagationIfWidgetSelected(e)) { - this.props.deleteSelectedWidget(); - } - }} - /> - { - if (this.stopPropagationIfWidgetSelected(e)) { - this.props.cutSelectedWidget(); - } - }} - /> - - ); - } public state = { registered: false, }; @@ -173,6 +67,8 @@ class Editor extends Component { if (applicationId && pageId) { this.props.initEditor(applicationId, pageId); } + this.props.handlePathUpdated(window.location.pathname); + this.unlisten = history.listen(this.handleHistoryChange); } shouldComponentUpdate(nextProps: Props, nextState: { registered: boolean }) { @@ -191,6 +87,15 @@ class Editor extends Component { ); } + componentWillUnmount() { + this.props.resetEditorRequest(); + if (typeof this.unlisten === "function") this.unlisten(); + } + + handleHistoryChange = (location: any) => { + this.props.handlePathUpdated(location.pathname); + }; + public render() { if (this.props.creatingOnboardingDatabase) { return ; @@ -216,7 +121,9 @@ class Editor extends Component { Editor | Appsmith - + + + @@ -242,10 +149,9 @@ const mapDispatchToProps = (dispatch: any) => { return { initEditor: (applicationId: string, pageId: string) => dispatch(initEditor(applicationId, pageId)), - copySelectedWidget: () => dispatch(copyWidget(true)), - pasteCopiedWidget: () => dispatch(pasteWidget()), - deleteSelectedWidget: () => dispatch(deleteSelectedWidget(true)), - cutSelectedWidget: () => dispatch(cutWidget()), + resetEditorRequest: () => dispatch(resetEditorRequest()), + handlePathUpdated: (pathName: string) => + dispatch(handlePathUpdated(pathName)), }; }; diff --git a/app/client/src/pages/UserAuth/StyledComponents.tsx b/app/client/src/pages/UserAuth/StyledComponents.tsx index a1a0818a96..edfba5708d 100644 --- a/app/client/src/pages/UserAuth/StyledComponents.tsx +++ b/app/client/src/pages/UserAuth/StyledComponents.tsx @@ -39,7 +39,7 @@ export const AuthCard = styled(Card)` text-align: center; padding: 0; margin: 0; - ${(props) => getTypographyByKey(props, "authCardHeader")} + ${(props) => getTypographyByKey(props, "cardHeader")} color: ${(props) => props.theme.colors.auth.headingText}; } & .form-message-container { @@ -120,13 +120,13 @@ export const FormActions = styled.div` `; export const SignUpLinkSection = styled.div` - ${(props) => getTypographyByKey(props, "authCardSubheader")} + ${(props) => getTypographyByKey(props, "cardSubheader")} color: ${(props) => props.theme.colors.auth.text}; text-align: center; `; export const ForgotPasswordLink = styled.div` - ${(props) => getTypographyByKey(props, "authCardSubheader")} + ${(props) => getTypographyByKey(props, "cardSubheader")} color: ${(props) => props.theme.colors.auth.text}; text-align: center; margin-top: ${(props) => props.theme.spaces[11]}px; diff --git a/app/client/src/reducers/index.tsx b/app/client/src/reducers/index.tsx index 482348f380..412e02caba 100644 --- a/app/client/src/reducers/index.tsx +++ b/app/client/src/reducers/index.tsx @@ -38,6 +38,7 @@ import { EvaluatedTreeState } from "./evaluationReducers/treeReducer"; import { EvaluationDependencyState } from "./evaluationReducers/dependencyReducer"; import { PageWidgetsReduxState } from "./uiReducers/pageWidgetsReducer"; import { OnboardingState } from "./uiReducers/onBoardingReducer"; +import { GlobalSearchReduxState } from "./uiReducers/globalSearchReducer"; import { ReleasesState } from "./uiReducers/releasesReducer"; import { LoadingEntitiesState } from "./evaluationReducers/loadingEntitiesReducer"; @@ -77,6 +78,7 @@ export interface AppState { datasourceName: DatasourceNameReduxState; theme: ThemeState; onBoarding: OnboardingState; + globalSearch: GlobalSearchReduxState; releases: ReleasesState; }; entities: { diff --git a/app/client/src/reducers/uiReducers/editorReducer.tsx b/app/client/src/reducers/uiReducers/editorReducer.tsx index f5ade60ca1..22ac72b8f4 100644 --- a/app/client/src/reducers/uiReducers/editorReducer.tsx +++ b/app/client/src/reducers/uiReducers/editorReducer.tsx @@ -28,6 +28,9 @@ const initialState: EditorReduxState = { }; const editorReducer = createReducer(initialState, { + [ReduxActionTypes.RESET_EDITOR_SUCCESS]: (state: EditorReduxState) => { + return { ...state, initialized: false }; + }, [ReduxActionTypes.INITIALIZE_EDITOR_SUCCESS]: (state: EditorReduxState) => { return { ...state, initialized: true }; }, diff --git a/app/client/src/reducers/uiReducers/globalSearchReducer.ts b/app/client/src/reducers/uiReducers/globalSearchReducer.ts new file mode 100644 index 0000000000..8699621722 --- /dev/null +++ b/app/client/src/reducers/uiReducers/globalSearchReducer.ts @@ -0,0 +1,49 @@ +import { createReducer } from "utils/AppsmithUtils"; +import { ReduxAction, ReduxActionTypes } from "constants/ReduxActionConstants"; +import { RecentEntity } from "components/editorComponents/GlobalSearch/utils"; + +const initialState: GlobalSearchReduxState = { + query: "", // used to prefill when opened via contextual help links + modalOpen: false, + recentEntities: [], + recentEntitiesRestored: false, +}; + +const globalSearchReducer = createReducer(initialState, { + [ReduxActionTypes.SET_GLOBAL_SEARCH_QUERY]: ( + state: GlobalSearchReduxState, + action: ReduxAction, + ) => ({ ...state, query: action.payload }), + [ReduxActionTypes.TOGGLE_SHOW_GLOBAL_SEARCH_MODAL]: ( + state: GlobalSearchReduxState, + ) => ({ ...state, modalOpen: !state.modalOpen }), + [ReduxActionTypes.SET_RECENT_ENTITIES]: ( + state: GlobalSearchReduxState, + action: ReduxAction>, + ) => ({ + ...state, + recentEntities: action.payload, + }), + [ReduxActionTypes.RESET_RECENT_ENTITIES]: ( + state: GlobalSearchReduxState, + ) => ({ + ...state, + recentEntities: [], + recentEntitiesRestored: false, + }), + [ReduxActionTypes.RESTORE_RECENT_ENTITIES_SUCCESS]: ( + state: GlobalSearchReduxState, + ) => ({ + ...state, + recentEntitiesRestored: true, + }), +}); + +export interface GlobalSearchReduxState { + query: string; + modalOpen: boolean; + recentEntities: Array; + recentEntitiesRestored: boolean; +} + +export default globalSearchReducer; diff --git a/app/client/src/reducers/uiReducers/index.tsx b/app/client/src/reducers/uiReducers/index.tsx index 3780a5f40e..101046890e 100644 --- a/app/client/src/reducers/uiReducers/index.tsx +++ b/app/client/src/reducers/uiReducers/index.tsx @@ -24,6 +24,7 @@ import datasourceNameReducer from "./datasourceNameReducer"; import pageCanvasStructureReducer from "./pageCanvasStructure"; import pageWidgetsReducer from "./pageWidgetsReducer"; import onBoardingReducer from "./onBoardingReducer"; +import globalSearchReducer from "./globalSearchReducer"; import releasesReducer from "./releasesReducer"; const uiReducer = combineReducers({ @@ -52,6 +53,7 @@ const uiReducer = combineReducers({ theme: themeReducer, confirmRunAction: confirmRunActionReducer, onBoarding: onBoardingReducer, + globalSearch: globalSearchReducer, releases: releasesReducer, }); export default uiReducer; diff --git a/app/client/src/sagas/ApplicationSagas.tsx b/app/client/src/sagas/ApplicationSagas.tsx index 83de640457..ae78707035 100644 --- a/app/client/src/sagas/ApplicationSagas.tsx +++ b/app/client/src/sagas/ApplicationSagas.tsx @@ -52,6 +52,7 @@ import { getCurrentPageId, } from "selectors/editorSelectors"; import { showCompletionDialog } from "./OnboardingSagas"; +import { deleteRecentAppEntities } from "utils/storage"; const getDefaultPageId = ( pages?: ApplicationPagePayload[], @@ -288,6 +289,7 @@ export function* deleteApplicationSaga( type: ReduxActionTypes.DELETE_APPLICATION_SUCCESS, payload: response.data, }); + yield call(deleteRecentAppEntities, request.applicationId); } } catch (error) { yield put({ diff --git a/app/client/src/sagas/GlobalSearchSagas.ts b/app/client/src/sagas/GlobalSearchSagas.ts new file mode 100644 index 0000000000..03274eb939 --- /dev/null +++ b/app/client/src/sagas/GlobalSearchSagas.ts @@ -0,0 +1,89 @@ +import { ReduxActionTypes, ReduxAction } from "constants/ReduxActionConstants"; +import { + all, + call, + put, + takeLatest, + select, + putResolve, + take, +} from "redux-saga/effects"; +import { setRecentAppEntities, fetchRecentAppEntities } from "utils/storage"; +import { + restoreRecentEntitiesSuccess, + setRecentEntities, +} from "actions/globalSearchActions"; +import { AppState } from "reducers"; +import { getIsEditorInitialized } from "selectors/editorSelectors"; +import { RecentEntity } from "components/editorComponents/GlobalSearch/utils"; + +export function* updateRecentEntity(actionPayload: ReduxAction) { + try { + const recentEntitiesRestored = yield select( + (state: AppState) => state.ui.globalSearch.recentEntitiesRestored, + ); + const isEditorInitialised = yield select(getIsEditorInitialized); + + const waitForEffects = []; + + if (!isEditorInitialised) { + waitForEffects.push(take(ReduxActionTypes.INITIALIZE_EDITOR_SUCCESS)); + } + + if (!recentEntitiesRestored) { + waitForEffects.push( + take(ReduxActionTypes.RESTORE_RECENT_ENTITIES_SUCCESS), + ); + } + + yield all(waitForEffects); + + const { payload: entity } = actionPayload; + let recentEntities = yield select( + (state: AppState) => state.ui.globalSearch.recentEntities, + ); + + recentEntities = recentEntities.slice(); + + const existingIndex = recentEntities.findIndex( + (recentEntity: { type: string; id: string }) => + recentEntity.id === entity.id, + ); + + if (existingIndex === -1) { + recentEntities.unshift(entity); + recentEntities = recentEntities.slice(0, 5); + } else { + recentEntities.splice(existingIndex, 1); + recentEntities.unshift(entity); + } + + yield put(setRecentEntities(recentEntities)); + if (entity?.params?.applicationId) { + yield call( + setRecentAppEntities, + recentEntities, + entity?.params?.applicationId, + ); + } + } catch (e) { + console.log(e, "error"); + } +} + +export function* restoreRecentEntities(actionPayload: ReduxAction) { + const { payload: appId } = actionPayload; + const recentAppEntities = yield call(fetchRecentAppEntities, appId); + yield putResolve(setRecentEntities(recentAppEntities)); + yield put(restoreRecentEntitiesSuccess()); +} + +export default function* globalSearchSagas() { + yield all([ + takeLatest(ReduxActionTypes.UPDATE_RECENT_ENTITY, updateRecentEntity), + takeLatest( + ReduxActionTypes.RESTORE_RECENT_ENTITIES_REQUEST, + restoreRecentEntities, + ), + ]); +} diff --git a/app/client/src/sagas/InitSagas.ts b/app/client/src/sagas/InitSagas.ts index 465fd9109f..7fd658d49d 100644 --- a/app/client/src/sagas/InitSagas.ts +++ b/app/client/src/sagas/InitSagas.ts @@ -36,6 +36,11 @@ import { getDefaultPageId } from "./selectors"; import { populatePageDSLsSaga } from "./PageSagas"; import log from "loglevel"; import * as Sentry from "@sentry/react"; +import { + restoreRecentEntitiesRequest, + resetRecentEntities, +} from "actions/globalSearchActions"; +import { resetEditorSuccess } from "actions/initActions"; function* initializeEditorSaga( initializeEditorAction: ReduxAction, @@ -52,6 +57,8 @@ function* initializeEditorSaga( put(fetchApplication(applicationId, APP_MODE.EDIT)), ]); + yield put(restoreRecentEntitiesRequest(applicationId)); + const resultOfPrimaryCalls = yield race({ success: all([ take(ReduxActionTypes.FETCH_PAGE_LIST_SUCCESS), @@ -218,6 +225,11 @@ export function* initializeAppViewerSaga( } } +function* resetEditorSaga() { + yield put(resetEditorSuccess()); + yield put(resetRecentEntities()); +} + export default function* watchInitSagas() { yield all([ takeLatest(ReduxActionTypes.INITIALIZE_EDITOR, initializeEditorSaga), @@ -225,5 +237,6 @@ export default function* watchInitSagas() { ReduxActionTypes.INITIALIZE_PAGE_VIEWER, initializeAppViewerSaga, ), + takeLatest(ReduxActionTypes.RESET_EDITOR_REQUEST, resetEditorSaga), ]); } diff --git a/app/client/src/sagas/RecentEntitiesSagas.ts b/app/client/src/sagas/RecentEntitiesSagas.ts new file mode 100644 index 0000000000..ebcb802655 --- /dev/null +++ b/app/client/src/sagas/RecentEntitiesSagas.ts @@ -0,0 +1,75 @@ +import { ReduxActionTypes, ReduxAction } from "constants/ReduxActionConstants"; +import { all, put, takeLatest } from "redux-saga/effects"; +import { updateRecentEntity } from "actions/globalSearchActions"; + +import { + matchApiPath, + matchDatasourcePath, + matchQueryPath, + matchBuilderPath, +} from "constants/routes"; +import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; + +const getRecentEntity = (pathName: string) => { + const builderMatch = matchBuilderPath(pathName); + if (builderMatch) + return { + type: "page", + id: builderMatch?.params?.pageId, + params: builderMatch?.params, + }; + + const apiMatch = matchApiPath(pathName); + if (apiMatch) + return { + type: "action", + id: apiMatch?.params?.apiId, + params: apiMatch?.params, + }; + + const queryMatch = matchQueryPath(pathName); + if (queryMatch) + return { + type: "action", + id: queryMatch.params?.queryId, + params: queryMatch?.params, + }; + + const datasourceMatch = matchDatasourcePath(pathName); + if (datasourceMatch) + return { + type: "datasource", + id: datasourceMatch?.params?.datasourceId, + params: datasourceMatch?.params, + }; + + return {}; +}; + +function* handleSelectWidget(action: ReduxAction<{ widgetId: string }>) { + const builderMatch = matchBuilderPath(window.location.pathname); + const { payload } = action; + const selectedWidget = payload.widgetId; + if (selectedWidget && selectedWidget !== MAIN_CONTAINER_WIDGET_ID) + yield put( + updateRecentEntity({ + type: "widget", + id: selectedWidget, + params: builderMatch?.params, + }), + ); +} + +function* handlePathUpdated(action: ReduxAction<{ pathName: string }>) { + const { type, id, params } = getRecentEntity(action.payload.pathName); + if (type && id && id.indexOf(":") === -1) { + yield put(updateRecentEntity({ type, id, params })); + } +} + +export default function* recentEntitiesSagas() { + yield all([ + takeLatest(ReduxActionTypes.SELECT_WIDGET, handleSelectWidget), + takeLatest(ReduxActionTypes.HANDLE_PATH_UPDATED, handlePathUpdated), + ]); +} diff --git a/app/client/src/sagas/index.tsx b/app/client/src/sagas/index.tsx index b4537c4bdf..95fd5cbf75 100644 --- a/app/client/src/sagas/index.tsx +++ b/app/client/src/sagas/index.tsx @@ -22,6 +22,8 @@ import themeSagas from "./ThemeSaga"; import evaluationsSaga from "./EvaluationsSaga"; import onboardingSaga from "./OnboardingSagas"; import actionExecutionChangeListeners from "./WidgetLoadingSaga"; +import globalSearchSagas from "./GlobalSearchSagas"; +import recentEntitiesSagas from "./RecentEntitiesSagas"; import log from "loglevel"; import * as sentry from "@sentry/react"; @@ -50,6 +52,8 @@ export function* rootSaga() { evaluationsSaga, onboardingSaga, actionExecutionChangeListeners, + globalSearchSagas, + recentEntitiesSagas, ]; yield all( sagas.map((saga) => diff --git a/app/client/src/selectors/entitiesSelector.ts b/app/client/src/selectors/entitiesSelector.ts index 69178c3aff..23a549eb14 100644 --- a/app/client/src/selectors/entitiesSelector.ts +++ b/app/client/src/selectors/entitiesSelector.ts @@ -11,6 +11,7 @@ import { Action } from "entities/Action"; import { find } from "lodash"; import ImageAlt from "assets/images/placeholder-image.svg"; import { CanvasWidgetsReduxState } from "../reducers/entityReducers/canvasWidgetsReducer"; +import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; export const getEntities = (state: AppState): AppState["entities"] => state.entities; @@ -283,3 +284,52 @@ export const getAppData = (state: AppState) => state.entities.app; export const getCanvasWidgets = (state: AppState): CanvasWidgetsReduxState => state.entities.canvasWidgets; + +const getPageWidgets = (state: AppState) => state.ui.pageWidgets; + +export const getAllWidgetsMap = createSelector( + getPageWidgets, + (widgetsByPage) => { + return Object.entries(widgetsByPage).reduce( + (res: any, [pageId, pageWidgets]: any) => { + const widgetsMap = Object.entries(pageWidgets).reduce( + (res, [widgetId, widget]: any) => { + let parentModalId; + let { parentId } = widget; + let parentWidget = pageWidgets[parentId]; + while (parentId && parentId !== MAIN_CONTAINER_WIDGET_ID) { + if (parentWidget?.type === "MODAL_WIDGET") { + parentModalId = parentId; + break; + } + parentId = parentWidget?.parentId; + parentWidget = pageWidgets[parentId]; + } + + return { + ...res, + [widgetId]: { ...widget, pageId, parentModalId }, + }; + }, + {}, + ); + + return { + ...res, + ...widgetsMap, + }; + }, + {}, + ); + }, +); + +export const getAllPageWidgets = createSelector( + getAllWidgetsMap, + (widgetsMap) => { + return Object.entries(widgetsMap).reduce( + (res: any[], [, widget]: any) => [...res, widget], + [], + ); + }, +); diff --git a/app/client/src/utils/AnalyticsUtil.tsx b/app/client/src/utils/AnalyticsUtil.tsx index dcb8f4f6f8..579d7dfc9f 100644 --- a/app/client/src/utils/AnalyticsUtil.tsx +++ b/app/client/src/utils/AnalyticsUtil.tsx @@ -106,7 +106,10 @@ export type EventName = | "ONBOARDING_NEXT_MISSION" | "ONBOARDING_GO_HOME" | "END_ONBOARDING" - | "ONBOARDING_COMPLETE"; + | "ONBOARDING_COMPLETE" + | "OPEN_OMNIBAR" + | "CLOSE_OMNIBAR" + | "NAVIGATE_TO_ENTITY_FROM_OMNIBAR"; function getApplicationId(location: Location) { const pathSplit = location.pathname.split("/"); diff --git a/app/client/src/utils/helpers.tsx b/app/client/src/utils/helpers.tsx index 12a1fb1b90..5f00a201d2 100644 --- a/app/client/src/utils/helpers.tsx +++ b/app/client/src/utils/helpers.tsx @@ -253,3 +253,10 @@ const playLottieAnimation = ( container.removeChild(el); }, duration); }; + +export const getSelectedText = () => { + if (typeof window.getSelection === "function") { + const selectionObj = window.getSelection(); + return selectionObj && selectionObj.toString(); + } +}; diff --git a/app/client/src/utils/hooks/dragResizeHooks.tsx b/app/client/src/utils/hooks/dragResizeHooks.tsx index ef4d4fe907..f31ff28353 100644 --- a/app/client/src/utils/hooks/dragResizeHooks.tsx +++ b/app/client/src/utils/hooks/dragResizeHooks.tsx @@ -1,6 +1,6 @@ import { useDispatch } from "react-redux"; import { ReduxActionTypes } from "constants/ReduxActionConstants"; -import { focusWidget } from "actions/widgetActions"; +import { focusWidget, selectWidget } from "actions/widgetActions"; import { useCallback, useEffect, useState } from "react"; export const useShowPropertyPane = () => { @@ -64,10 +64,7 @@ export const useWidgetSelection = () => { return { selectWidget: useCallback( (widgetId?: string) => { - dispatch({ - type: ReduxActionTypes.SELECT_WIDGET, - payload: { widgetId }, - }); + dispatch(selectWidget(widgetId)); }, [dispatch], ), diff --git a/app/client/src/utils/storage.ts b/app/client/src/utils/storage.ts index 0f7b050442..4bf126e284 100644 --- a/app/client/src/utils/storage.ts +++ b/app/client/src/utils/storage.ts @@ -8,6 +8,7 @@ const STORAGE_KEYS: { [id: string]: string } = { DELETED_WIDGET_PREFIX: "DeletedWidget-", ONBOARDING_STATE: "OnboardingState", ONBOARDING_WELCOME_STATE: "OnboardingWelcomeState", + RECENT_ENTITIES: "RecentEntities", }; const store = localforage.createInstance({ @@ -139,3 +140,44 @@ export const getOnboardingWelcomeState = async () => { ); } }; + +export const setRecentAppEntities = async (entities: any, appId: string) => { + try { + const recentEntities = + ((await store.getItem(STORAGE_KEYS.RECENT_ENTITIES)) as Record< + string, + any + >) || {}; + recentEntities[appId] = entities; + await store.setItem(STORAGE_KEYS.RECENT_ENTITIES, recentEntities); + } catch (error) { + console.log("An error occurred while saving recent entities", error); + } +}; + +export const fetchRecentAppEntities = async (appId: string) => { + try { + const recentEntities = (await store.getItem( + STORAGE_KEYS.RECENT_ENTITIES, + )) as Record; + return (recentEntities && recentEntities[appId]) || []; + } catch (error) { + console.log("An error occurred while fetching recent entities", error); + } +}; + +export const deleteRecentAppEntities = async (appId: string) => { + try { + const recentEntities = + ((await store.getItem(STORAGE_KEYS.RECENT_ENTITIES)) as Record< + string, + any + >) || {}; + if (typeof recentEntities === "object") { + delete recentEntities[appId]; + } + await store.setItem(STORAGE_KEYS.RECENT_ENTITIES, recentEntities); + } catch (error) { + console.log("An error occurred while saving recent entities", error); + } +}; diff --git a/app/client/yarn.lock b/app/client/yarn.lock index 0b30e7388b..0124769778 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -4010,6 +4010,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.162.tgz#65d78c397e0d883f44afbf1f7ba9867022411470" integrity sha512-alvcho1kRUnnD1Gcl4J+hK0eencvzq9rmzvFPRmP5rPHx9VVsJj6bKLTATPVf9ktgv4ujzh7T+XWKp+jhuODig== +"@types/marked@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@types/marked/-/marked-1.2.2.tgz#1f858a0e690247ecf3b2eef576f98f86e8d960d4" + integrity sha512-wLfw1hnuuDYrFz97IzJja0pdVsC0oedtS4QsKH1/inyW9qkLQbXgMUqEQT0MVtUBx3twjWeInUfjQbhBVLECXw== + "@types/mdast@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb" @@ -6995,6 +7000,11 @@ compression@^1.7.4: safe-buffer "5.1.2" vary "~1.1.2" +compute-scroll-into-view@^1.0.16: + version "1.0.16" + resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" + integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -12661,6 +12671,11 @@ markdown-to-jsx@^6.10.3, markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" +marked@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-2.0.0.tgz#9662bbcb77ebbded0662a7be66ff929a8611cee5" + integrity sha512-NqRSh2+LlN2NInpqTQnS614Y/3NkVMFFU6sJlRFEpxJ/LHuK/qJECH7/fXZjk4VZstPW/Pevjil/VtSONsLc7Q== + marker-clusterer-plus@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/marker-clusterer-plus/-/marker-clusterer-plus-2.1.4.tgz#f8eff74d599dab3b7d0e3fed5264ea0e704f5d67" @@ -14101,6 +14116,11 @@ path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" +path-to-regexp@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.0.tgz#f7b3803336104c346889adece614669230645f38" + integrity sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg== + path-type@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -17060,6 +17080,13 @@ scriptjs@^2.5.8: resolved "https://registry.yarnpkg.com/scriptjs/-/scriptjs-2.5.9.tgz#343915cd2ec2ed9bfdde2b9875cd28f59394b35f" integrity sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg== +scroll-into-view-if-needed@^2.2.26: + version "2.2.26" + resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.26.tgz#e4917da0c820135ff65ad6f7e4b7d7af568c4f13" + integrity sha512-SQ6AOKfABaSchokAmmaxVnL9IArxEnLEX9j4wAZw+x4iUTb40q7irtHG3z4GtAWz5veVZcCnubXDBRyLVQaohw== + dependencies: + compute-scroll-into-view "^1.0.16" + scss-tokenizer@^0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1"