Omnibar global search (#2903)

This commit is contained in:
Rishabh Saxena 2021-03-08 13:54:12 +05:30 committed by GitHub
parent 2dfc8ebf8a
commit 99b3fa6bb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 2792 additions and 202 deletions

View File

@ -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);
});
});
});
});

View File

@ -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"
}

View File

@ -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",

View File

@ -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<RecentEntity>) => ({
type: ReduxActionTypes.SET_RECENT_ENTITIES,
payload,
});

View File

@ -14,3 +14,11 @@ export const initEditor = (
pageId,
},
});
export const resetEditorRequest = () => ({
type: ReduxActionTypes.RESET_EDITOR_REQUEST,
});
export const resetEditorSuccess = () => ({
type: ReduxActionTypes.RESET_EDITOR_SUCCESS,
});

View File

@ -0,0 +1,6 @@
import { ReduxActionTypes } from "constants/ReduxActionConstants";
export const handlePathUpdated = (pathName: string) => ({
type: ReduxActionTypes.HANDLE_PATH_UPDATED,
payload: { pathName },
});

View File

@ -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,

View File

@ -0,0 +1,13 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.332 3.69141L9.46094 2.87109L8.64062 0H2.89844C2.21886 0 1.66797 0.550894 1.66797 1.23047V12.7695C1.66797 13.4491 2.21886 14 2.89844 14H11.1016C11.7811 14 12.332 13.4491 12.332 12.7695V3.69141Z" fill="#4086F4"/>
<path d="M12.332 3.69141V12.7695C12.332 13.4491 11.7811 14 11.1016 14H7V0H8.64062L9.46094 2.87109L12.332 3.69141Z" fill="#4175DF"/>
<path d="M12.332 3.69141H9.46094C9.00977 3.69141 8.64062 3.32227 8.64062 2.87109V0C8.74727 0 8.85391 0.0410156 8.92771 0.123074L12.209 3.40432C12.291 3.47813 12.332 3.58477 12.332 3.69141Z" fill="#80AEF8"/>
<path d="M9.46094 6.58984H4.53906C4.31236 6.58984 4.12891 6.40639 4.12891 6.17969C4.12891 5.95298 4.31236 5.76953 4.53906 5.76953H9.46094C9.68764 5.76953 9.87109 5.95298 9.87109 6.17969C9.87109 6.40639 9.68764 6.58984 9.46094 6.58984Z" fill="#FFF5F5"/>
<path d="M9.46094 8.23047H4.53906C4.31236 8.23047 4.12891 8.04702 4.12891 7.82031C4.12891 7.59361 4.31236 7.41016 4.53906 7.41016H9.46094C9.68764 7.41016 9.87109 7.59361 9.87109 7.82031C9.87109 8.04702 9.68764 8.23047 9.46094 8.23047Z" fill="#FFF5F5"/>
<path d="M9.46094 9.87109H4.53906C4.31236 9.87109 4.12891 9.68764 4.12891 9.46094C4.12891 9.23423 4.31236 9.05078 4.53906 9.05078H9.46094C9.68764 9.05078 9.87109 9.23423 9.87109 9.46094C9.87109 9.68764 9.68764 9.87109 9.46094 9.87109Z" fill="#FFF5F5"/>
<path d="M7.82031 11.5117H4.53906C4.31236 11.5117 4.12891 11.3283 4.12891 11.1016C4.12891 10.8749 4.31236 10.6914 4.53906 10.6914H7.82031C8.04702 10.6914 8.23047 10.8749 8.23047 11.1016C8.23047 11.3283 8.04702 11.5117 7.82031 11.5117Z" fill="#FFF5F5"/>
<path d="M7 11.5117H7.82031C8.04702 11.5117 8.23047 11.3283 8.23047 11.1016C8.23047 10.8749 8.04702 10.6914 7.82031 10.6914H7V11.5117Z" fill="#E3E7EA"/>
<path d="M7 9.87109H9.46094C9.68764 9.87109 9.87109 9.68764 9.87109 9.46094C9.87109 9.23423 9.68764 9.05078 9.46094 9.05078H7V9.87109Z" fill="#E3E7EA"/>
<path d="M7 8.23047H9.46094C9.68764 8.23047 9.87109 8.04702 9.87109 7.82031C9.87109 7.59361 9.68764 7.41016 9.46094 7.41016H7V8.23047Z" fill="#E3E7EA"/>
<path d="M7 6.58984H9.46094C9.68764 6.58984 9.87109 6.40639 9.87109 6.17969C9.87109 5.95298 9.68764 5.76953 9.46094 5.76953H7V6.58984Z" fill="#E3E7EA"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 6V0H12V6H6ZM0 6V1H5V6H0ZM1 11V7H5V11H1ZM6 7V12H11V7H6Z" fill="#5BB749"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="1.5" stroke="white" stroke-opacity="0.6"/>
<path d="M6 10L10 6M10 6H6.92308M10 6V9.07692" stroke="white" stroke-opacity="0.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 327 B

View File

@ -0,0 +1,4 @@
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.33325 3.33337V6.66669L10.1866 8.36004L10.6666 7.55004L8.33325 6.16669V3.33337H7.33325Z" fill="#FCD43E"/>
<path d="M7.99666 0C4.68 0 2 2.68666 2 6H0L2.59666 8.59665L2.64331 8.69331L5.33334 6H3.33334C3.33334 3.42334 5.42334 1.33334 8 1.33334C10.5767 1.33334 12.6667 3.42334 12.6667 6C12.6667 8.57666 10.5767 10.6667 8 10.6667C6.71 10.6667 5.54666 10.14 4.70334 9.29666L3.76 10.24C4.84334 11.3267 6.34 12 7.99666 12C11.3133 12 14 9.31334 14 6C14 2.68666 11.3133 0 7.99666 0Z" fill="#FCD43E"/>
</svg>

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -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 = <ChevronRight />;
break;
case "link":
returnIcon = <LinkIcon />;
break;
case "help":
returnIcon = <HelpIcon />;
break;
case "close-modal":
returnIcon = <CloseModalIcon />;
break;

View File

@ -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<Props, State> {
if (!algolia.enabled) return null;
return (
<SearchContainer className="ais-InstantSearch t--docSearchModal">
<Icon
className="t--docsMinimize"
style={{
position: "absolute",
top: 6,
right: 10,
cursor: "pointer",
zIndex: 1,
}}
icon="minus"
color="white"
iconSize={14}
onClick={this.handleClose}
/>
{!this.props.hideMinimizeBtn && (
<Icon
className="t--docsMinimize"
style={{
position: "absolute",
top: 6,
right: 10,
cursor: "pointer",
zIndex: 1,
}}
icon="minus"
color="white"
iconSize={14}
onClick={this.handleClose}
/>
)}
<InstantSearch
indexName={algolia.indexName}
searchClient={searchClient}
>
<Configure hitsPerPage={this.props.hitsPerPage} />
<HelpContainer>
<Header>
<StyledPoweredBy />
<SearchBox
onChange={this.onSearchValueChange}
defaultRefinement={this.props.defaultRefinement}
/>
</Header>
<HelpBody>
{!this.props.hideSearch && (
<Header>
<StyledPoweredBy />
<SearchBox
onChange={this.onSearchValueChange}
defaultRefinement={this.props.defaultRefinement}
/>
</Header>
)}
<HelpBody hideSearch={this.props.hideSearch}>
{this.state.showResults ? (
<Hits hitComponent={Hit as any} />
) : (

View File

@ -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;

View File

@ -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 (
<StyledActionLink isActiveItem={isActiveItem}>
<Icon
name="link"
size={IconSize.LARGE}
fillColor={theme.colors.globalSearch.searchItemText}
onClick={(e) => {
e.stopPropagation(); // to prevent toggleModal getting called twice
searchContext?.handleItemLinkClick(item, "SEARCH_ITEM_ICON_CLICK");
}}
/>
</StyledActionLink>
);
},
);
export default ActionLink;

View File

@ -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 (
<InstantSearch
searchState={{ query: queryInState }}
indexName={algolia.indexName}
searchClient={searchClient}
>
{children}
</InstantSearch>
);
};
export default Search;

View File

@ -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<number>;
};
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 ? (
<div dangerouslySetInnerHTML={{ __html: 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 (
<StyledHitEnterMessageContainer
style={{ display: "flex", alignItems: "center" }}
>
&#10024; Press <StyledKey>&#8629;</StyledKey> to navigate to
<StyledHighlightWrapper>
<Highlight match={query} text={title} />
</StyledHighlightWrapper>
<ActionLink item={item} isActiveItem={true} />
</StyledHitEnterMessageContainer>
);
};
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<HTMLDivElement>(null);
const onScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
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 (
<Container onScroll={onScroll} ref={containerRef}>
<Component item={activeItem} query={props.query} />
</Container>
);
};
export default Description;

View File

@ -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<SearchContextType | undefined>(
undefined,
);
export default SearchContext;

View File

@ -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<Props> {
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 (
<Hotkeys>
{this.hotKeysConfig.map(
({ combo, onKeyDown, allowInInput, label, group }, index) => (
<Hotkey
key={index}
global={false}
combo={combo}
onKeyDown={onKeyDown}
label={label}
allowInInput={allowInInput}
group={group}
/>
),
)}
</Hotkeys>
);
}
render() {
return <div>{this.props.children}</div>;
}
}
export default GlobalSearchHotKeys;

View File

@ -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() ? <span>&#8984;</span> : "ctrl");
const comboText = <>{modText()} + K</>;
type Props = {
toggleShowModal: () => void;
};
const HelpBar = ({ toggleShowModal }: Props) => {
return (
<StyledHelpBar
onClick={toggleShowModal}
className="t--global-search-modal-trigger"
>
<Text type={TextType.P2}>{HELPBAR_PLACEHOLDER}</Text>
<Text type={TextType.P3} italic>
{comboText}
</Text>
</StyledHelpBar>
);
};
const mapDispatchToProps = (dispatch: any) => ({
toggleShowModal: () => {
AnalyticsUtil.logEvent("OPEN_OMNIBAR", { source: "NAVBAR_CLICK" });
dispatch(toggleShowGlobalSearchModal());
},
});
export default connect(null, mapDispatchToProps)(HelpBar);

View File

@ -0,0 +1,32 @@
import React from "react";
const Highlight = ({ match, text }: { match: string; text: string }) => {
if (!match) return <span>{text}</span>;
const regEx = new RegExp(match, "ig");
const parts = text?.split(regEx);
if (parts?.length === 1) return <span>{text}</span>;
let lastIndex = 0;
return (
<span>
{parts?.map((part, index) => {
lastIndex += Math.max(part.length, 0);
const result = (
<React.Fragment key={index}>
{part}
{index !== parts.length - 1 && (
<span className="search-highlighted">
{text.slice(lastIndex, lastIndex + match.length)}
</span>
)}
</React.Fragment>
);
lastIndex += match.length;
return result;
})}
</span>
);
};
export default Highlight;

View File

@ -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 = () => (
<Container>
<img alt="No data" src={NoSearchDataImage} />
<div className="no-data-title">{NO_SEARCH_DATA_TEXT}</div>
</Container>
);
export default ResultsNotFound;

View File

@ -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<HTMLInputElement>) => {
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 (
<Container>
<InputContainer>
<input
value={query}
onChange={(e) => updateSearchQuery(e.currentTarget.value)}
autoFocus
onKeyDown={handleKeyDown}
placeholder={OMNIBAR_PLACEHOLDER}
className="t--global-search-input"
/>
{query && (
<Icon
name="close"
className="t--global-clear-input"
onClick={() => updateSearchQuery("")}
/>
)}
</InputContainer>
</Container>
);
};
export default connectSearchBox<SearchBoxProps>(SearchBox);

View File

@ -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) => (
<StyledDocsSearchModal>
<Overlay
isOpen={modalOpen}
onClose={toggleShow}
hasBackdrop={true}
usePortal={false}
onClosing={() => {
AnalyticsUtil.logEvent("CLOSE_OMNIBAR");
}}
transitionDuration={25}
>
<div className={`${Classes.OVERLAY_CONTENT} t--global-search-modal`}>
{children}
</div>
</Overlay>
</StyledDocsSearchModal>
);
export default DocsSearchModal;

View File

@ -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 (
<>
<StyledDocumentIcon />
<ItemTitle>
<span>
<AlgoliaHighlight attribute="title" hit={props.item} />
</span>
<ActionLink item={props.item} isActiveItem={props.isActiveItem} />
</ItemTitle>
</>
);
};
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 (
<>
<WidgetIconWrapper>{getWidgetIcon(type)}</WidgetIconWrapper>
<ItemTitle>
<Highlight match={query} text={title} />
<ActionLink item={props.item} isActiveItem={props.isActiveItem} />
</ItemTitle>
</>
);
};
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 (
<>
<ActionIconWrapper>{icon}</ActionIconWrapper>
<ItemTitle>
<Highlight match={query} text={title} />
<ActionLink item={props.item} isActiveItem={props.isActiveItem} />
</ItemTitle>
</>
);
};
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}
<ItemTitle>
<Highlight match={query} text={title} />
<ActionLink item={props.item} isActiveItem={props.isActiveItem} />
</ItemTitle>
</>
);
};
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}
<ItemTitle>
<Highlight match={query} text={title} />
<ActionLink item={props.item} isActiveItem={props.isActiveItem} />
</ItemTitle>
</>
);
};
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 }) => (
<StyledSectionTitleContainer>
<img className="section-title__icon" src={item.icon} />
<span className="section-title__text">{item.title}</span>
</StyledSectionTitleContainer>
);
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<HTMLDivElement>(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 (
<SearchItemContainer
ref={itemRef}
onClick={() => {
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}
>
<Item item={item} query={query} isActiveItem={isActiveItem} />
</SearchItemContainer>
);
};
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 (
<SearchResultsContainer>
{searchResults.map((item: SearchItem, index: number) => (
<SearchItemComponent
key={index}
index={index}
item={item}
query={query}
/>
))}
</SearchResultsContainer>
);
};
export default SearchResults;

View File

@ -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<Props, IHit>(SearchResults);

View File

@ -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<ExplorerURLParams>();
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<Array<DocSearchItem>>([]);
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 (
<SearchContext.Provider value={searchContext}>
<GlobalSearchHotKeys {...hotKeyProps}>
<SearchModal toggleShow={toggleShow} modalOpen={modalOpen}>
<AlgoliaSearchWrapper query={query}>
<StyledContainer>
<SearchBox query={query} setQuery={setQuery} />
<div className="main">
<SetSearchResults
setDocumentationSearchResults={setDocumentationSearchResults}
/>
{searchResults.length > 0 ? (
<>
<SearchResults
searchResults={searchResults}
query={query}
/>
<Separator />
<Description
activeItem={activeItem}
activeItemType={activeItemType}
query={query}
scrollPositionRef={scrollPositionRef}
/>
</>
) : (
<ResultsNotFound />
)}
</div>
</StyledContainer>
</AlgoliaSearchWrapper>
</SearchModal>
</GlobalSearchHotKeys>
</SearchContext.Provider>
);
};
export default GlobalSearch;

View File

@ -0,0 +1,44 @@
// eslint-disable-next-line
import parseDocumentationContent from "./parseDocumentationContent";
const expectedResult = `<h1><ais-highlight-0000000000>Security</ais-highlight-0000000000> <a class="documentation-cta" href="https://docs.appsmith.com/security" target="_blank">Open Documentation</a></h1><h2>Does Appsmith store my data?</h2>
<p>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 <ais-highlight-0000000000>security</ais-highlight-0000000000> breaches. Such a routing ensures <ais-highlight-0000000000>security</ais-highlight-0000000000> of your systems and data.</p>
<h2><ais-highlight-0000000000>Security</ais-highlight-0000000000> measures within Appsmith</h2>
<p>Appsmith applications are secure-by-default. The <ais-highlight-0000000000>security</ais-highlight-0000000000> measures implemented for Appsmith installations are:</p>
<ul>
<li>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.</li>
<li>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 <ais-highlight-0000000000>security</ais-highlight-0000000000>.</li>
<li>Appsmith Cloud will only connect to your databases/API endpoints through whitelisted IPs: 18.223.74.85 &amp; 3.131.104.27. This ensures that you only have to expose database access to specific IPs when using our cloud offering.</li>
<li>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.</li>
<li>Internal access to Appsmith Cloud is controlled through 2-factor authentication system along with audit logs.</li>
<li>Maintain an open channel of communication with <ais-highlight-0000000000>security</ais-highlight-0000000000> researchers to allow them to report <ais-highlight-0000000000>security</ais-highlight-0000000000> vulnerabilities responsibly. If you notice a <ais-highlight-0000000000>security</ais-highlight-0000000000> vulnerability, please email <a href="mailto:security@appsmith.com" target="_blank"><ais-highlight-0000000000>security</ais-highlight-0000000000>@appsmith.com</a> and we'll resolve them ASAP.</li>
</ul>`;
const sampleTitleResponse = `<ais-highlight-0000000000>Security</ais-highlight-0000000000>`;
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 <ais-highlight-0000000000>security</ais-highlight-0000000000> breaches. Such a routing ensures <ais-highlight-0000000000>security</ais-highlight-0000000000> of your systems and data.
# <ais-highlight-0000000000>Security</ais-highlight-0000000000> measures within Appsmith
Appsmith applications are secure-by-default. The <ais-highlight-0000000000>security</ais-highlight-0000000000> 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 <ais-highlight-0000000000>security</ais-highlight-0000000000>.
* 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 <ais-highlight-0000000000>security</ais-highlight-0000000000> researchers to allow them to report <ais-highlight-0000000000>security</ais-highlight-0000000000> vulnerabilities responsibly. If you notice a <ais-highlight-0000000000>security</ais-highlight-0000000000> vulnerability, please email [<ais-highlight-0000000000>security</ais-highlight-0000000000>@appsmith.com](mailto:<ais-highlight-0000000000>security</ais-highlight-0000000000>@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);
});
});

View File

@ -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 = `<a class="documentation-cta" href="${href}" target="_blank">Open Documentation</a>`;
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 = `<h2>${match.innerHTML}</h2>`;
});
let firstChild = documentObj.querySelector("body")
?.firstChild as HTMLElement | null;
const matchesExactly = rawTitle === firstChild?.innerHTML;
// additional space for word-break
if (matchesExactly && firstChild) {
firstChild.outerHTML = `<h1>${firstChild?.innerHTML} </h1>`;
} 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(
`&lt;${algoliaHighlightTag}&gt;|&lt;/${algoliaHighlightTag}&gt;`,
"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;

View File

@ -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;

View File

@ -0,0 +1,140 @@
import { Datasource } from "entities/Datasource";
import { useEffect, useState } from "react";
export type RecentEntity = {
type: string;
id: string;
params?: Record<string, string | undefined>;
};
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<DocSearchItem[]>([]);
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,
}));
};

View File

@ -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 ? (
<PositionStyle>
<ControlGroup>
<HelpControl
type={props.type}
show={selectedWidget === props.widgetId}
/>
<SettingsControl
toggleSettings={togglePropertyEditor}
activity={currentActivity}

View File

@ -881,6 +881,27 @@ type ColorType = {
activeTabBorderBottom: string;
activeTabText: string;
};
globalSearch: {
containerBackground: string;
activeSearchItemBackground: string;
searchInputText: string;
containerShadow: string;
separator: string;
searchItemHighlight: string;
searchItemText: string;
highlightedTextUnderline: string;
documentationCtaBackground: string;
documentationCtaText: string;
emptyStateText: string;
navigateUsingEnterSection: string;
codeBackground: string;
documentLink: string;
helpBarBackground: string;
helpButtonBackground: string;
helpBarBorder: string;
sectionTitle: string;
navigateToEntityEnterkey: string;
};
gif: {
overlay: string;
text: string;
@ -916,7 +937,33 @@ const formMessage = {
},
};
const globalSearch = {
containerBackground:
"linear-gradient(0deg, rgba(43, 43, 43, 0.9), rgba(43, 43, 43, 0.9)), linear-gradient(119.61deg, rgba(35, 35, 35, 0.01) 0.43%, rgba(49, 49, 49, 0.01) 100.67%);",
activeSearchItemBackground: "rgba(0, 0, 0, 0.24)",
searchInputText: "#fff",
containerShadow: "0px 0px 32px 8px rgba(0, 0, 0, 0.25)",
separator: "#424242",
searchItemHighlight: "#fff",
searchItemText: "rgba(255, 255, 255, 0.6)",
highlightedTextUnderline: "#03B365",
helpBarText: "#C2C2C2",
documentationCtaBackground: "rgba(3, 179, 101, 0.1)",
documentationCtaText: "#03B365",
emptyStateText: "#ABABAB",
navigateUsingEnterSection: "#154E6B",
codeBackground: "#494949",
documentLink: "#54a9fb",
helpBarBackground: "#000",
helpButtonBackground: "#333333",
helpBarBorder: "#404040",
helpButtonBorder: "#404040",
sectionTitle: "#D4D4D4",
navigateToEntityEnterkey: "#3DA5D9",
};
export const dark: ColorType = {
globalSearch,
header: {
separator: darkShades[4],
appName: darkShades[7],
@ -1304,6 +1351,7 @@ export const dark: ColorType = {
};
export const light: ColorType = {
globalSearch,
header: {
separator: "#E0DEDE",
appName: lightShades[8],
@ -1780,18 +1828,30 @@ export const theme: Theme = {
letterSpacing: -0.24,
fontWeight: "normal",
},
authCardHeader: {
cardHeader: {
fontStyle: "normal",
fontWeight: 600,
fontSize: 25,
lineHeight: 20,
},
authCardSubheader: {
cardSubheader: {
fontStyle: "normal",
fontWeight: "normal",
fontSize: 15,
lineHeight: 20,
},
largeH1: {
fontStyle: "normal",
fontWeight: "bold",
fontSize: 28,
lineHeight: 36,
},
spacedOutP1: {
fontStyle: "normal",
fontWeight: "normal",
fontSize: 14,
lineHeight: 24,
},
},
iconSizes: {
XXS: 8,
@ -2026,7 +2086,6 @@ export const theme: Theme = {
export const scrollbarLight = css<{ backgroundColor?: Color }>`
scrollbar-color: ${(props) => props.theme.colors.paneText};
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;

View File

@ -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;

View File

@ -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",
};

View File

@ -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";

View File

@ -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);

View File

@ -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");

View File

@ -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) => {
)}
</Boxed>
</HeaderSection>
<HeaderSection>
<HelpBar />
<HelpButton />
</HeaderSection>
<HeaderSection>
<Boxed step={OnboardingStep.FINISH}>
<SaveStatusContainer className={"t--save-status-container"}>
@ -294,8 +306,8 @@ export const EditorHeader = (props: EditorHeaderProps) => {
</ProfileDropdownContainer>
)}
</HeaderSection>
<HelpModal page={"Editor"} />
<OnboardingHelper />
<GlobalSearch />
</HeaderWrapper>
</ThemeProvider>
);

View File

@ -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,

View File

@ -43,15 +43,42 @@ export const navigateToCanvas = (
}
};
export const useNavigateToWidget = () => {
const params = useParams<ExplorerURLParams>();
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<ExplorerURLParams>();
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 = {

View File

@ -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<Props> {
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 (
<Hotkeys>
<Hotkey
global={true}
combo="mod + f"
label="Search entities"
onKeyDown={(e: any) => {
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();
}}
/>
<Hotkey
combo="mod + k"
onKeyDown={(e: KeyboardEvent) => {
console.log("toggleShowGlobalSearchModal");
e.preventDefault();
this.props.toggleShowGlobalSearchModal();
AnalyticsUtil.logEvent("OPEN_OMNIBAR", { source: "HOTKEY_COMBO" });
}}
allowInInput={false}
label="Show omnibar"
global={true}
/>
<Hotkey
global={true}
combo="mod + c"
label="Copy Widget"
group="Canvas"
onKeyDown={(e: any) => {
if (this.stopPropagationIfWidgetSelected(e)) {
this.props.copySelectedWidget();
}
}}
/>
<Hotkey
global={true}
combo="mod + v"
label="Paste Widget"
group="Canvas"
onKeyDown={() => {
this.props.pasteCopiedWidget();
}}
/>
<Hotkey
global={true}
combo="backspace"
label="Delete Widget"
group="Canvas"
onKeyDown={(e: any) => {
if (this.stopPropagationIfWidgetSelected(e) && isMac()) {
this.props.deleteSelectedWidget();
}
}}
/>
<Hotkey
global={true}
combo="del"
label="Delete Widget"
group="Canvas"
onKeyDown={(e: any) => {
if (this.stopPropagationIfWidgetSelected(e)) {
this.props.deleteSelectedWidget();
}
}}
/>
<Hotkey
global={true}
combo="mod + x"
label="Cut Widget"
group="Canvas"
onKeyDown={(e: any) => {
if (this.stopPropagationIfWidgetSelected(e)) {
this.props.cutSelectedWidget();
}
}}
/>
</Hotkeys>
);
}
render() {
return <div>{this.props.children}</div>;
}
}
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);

View File

@ -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 = () => (
<StyledTrigger>
<Icon name="help" size={IconSize.XS} />
</StyledTrigger>
);
const onOpened = () => {
AnalyticsUtil.logEvent("OPEN_HELP", { page: "Editor" });
};
const HelpButton = () => {
return (
<Popover
modifiers={{
offset: {
enabled: true,
offset: "0, 6",
},
}}
minimal
position={Position.BOTTOM_RIGHT}
onOpened={onOpened}
popoverClassName="navbar-help-popover"
>
<>
<HelpPopoverStyle />
<Trigger />
</>
<div style={{ width: HELP_MODAL_WIDTH }}>
<DocumentationSearch hitsPerPage={4} hideSearch hideMinimizeBtn />
</div>
</Popover>
);
};
export default HelpButton;

View File

@ -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 (
<Icon
onClick={openHelpModal}
color={theme.colors.paneSectionLabel}
icon="help"
iconSize={16}
/>
);
});
export default PropertyPaneHelpButton;

View File

@ -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) => {
/>
</Tooltip>
<Tooltip
content={
<div>
<span>You can connect data from your API by adding </span>
<BindingText>{`{{apiName.data}}`}</BindingText>
<span> to a widget property</span>
</div>
}
content={<span>Explore widget related docs</span>}
position={Position.TOP}
hoverOpenDelay={200}
boundary="window"
>
<Icon color={theme.colors.paneSectionLabel} icon="help" iconSize={16} />
<PropertyPaneHelpButton />
</Tooltip>
<Tooltip content="Close" position={Position.TOP} hoverOpenDelay={200}>
<Icon

View File

@ -2,6 +2,7 @@ import React, { Component } from "react";
import { Helmet } from "react-helmet";
import { connect } from "react-redux";
import { RouteComponentProps, withRouter } from "react-router-dom";
import { Spinner } from "@blueprintjs/core";
import { BuilderRouteParams } from "constants/routes";
import { AppState } from "reducers";
import MainContainer from "./MainContainer";
@ -15,32 +16,22 @@ import {
getIsPublishingApplication,
getPublishingError,
} from "selectors/editorSelectors";
import { Hotkey, Hotkeys, Spinner } from "@blueprintjs/core";
import { HotkeysTarget } from "@blueprintjs/core/lib/esnext/components/hotkeys/hotkeysTarget.js";
import { initEditor } from "actions/initActions";
import { initEditor, resetEditorRequest } from "actions/initActions";
import { editorInitializer } from "utils/EditorUtils";
import {
ENTITY_EXPLORER_SEARCH_ID,
WIDGETS_SEARCH_ID,
} from "constants/Explorer";
import CenteredWrapper from "components/designSystems/appsmith/CenteredWrapper";
import { getCurrentUser } from "selectors/usersSelectors";
import { User } from "constants/userConstants";
import ConfirmRunModal from "pages/Editor/ConfirmRunModal";
import * as Sentry from "@sentry/react";
import {
copyWidget,
cutWidget,
deleteSelectedWidget,
pasteWidget,
} from "actions/widgetActions";
import { isMac } from "utils/helpers";
import { getSelectedWidget } from "selectors/ui";
import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants";
import Welcome from "./Welcome";
import { getThemeDetails, ThemeMode } from "selectors/themeSelectors";
import { ThemeProvider } from "styled-components";
import { Theme } from "constants/DefaultTheme";
import GlobalHotKeys from "./GlobalHotKeys";
import { handlePathUpdated } from "actions/recentEntityActions";
import history from "utils/history";
type EditorProps = {
currentApplicationId?: string;
@ -52,115 +43,18 @@ type EditorProps = {
isEditorInitializeError: boolean;
errorPublishing: boolean;
creatingOnboardingDatabase: boolean;
copySelectedWidget: () => void;
pasteCopiedWidget: () => void;
deleteSelectedWidget: () => void;
cutSelectedWidget: () => void;
user?: User;
selectedWidget?: string;
lightTheme: Theme;
resetEditorRequest: () => void;
handlePathUpdated: (pathName: string) => void;
};
type Props = EditorProps & RouteComponentProps<BuilderRouteParams>;
const getSelectedText = () => {
if (typeof window.getSelection === "function") {
const selectionObj = window.getSelection();
return selectionObj && selectionObj.toString();
}
};
@HotkeysTarget
class Editor extends Component<Props> {
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 (
<Hotkeys>
<Hotkey
global={true}
combo="mod + f"
label="Search entities"
onKeyDown={(e: any) => {
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();
}}
/>
<Hotkey
global={true}
combo="mod + c"
label="Copy Widget"
group="Canvas"
onKeyDown={(e: any) => {
if (this.stopPropagationIfWidgetSelected(e)) {
this.props.copySelectedWidget();
}
}}
/>
<Hotkey
global={true}
combo="mod + v"
label="Paste Widget"
group="Canvas"
onKeyDown={() => {
this.props.pasteCopiedWidget();
}}
/>
<Hotkey
global={true}
combo="backspace"
label="Delete Widget"
group="Canvas"
onKeyDown={(e: any) => {
if (this.stopPropagationIfWidgetSelected(e) && isMac()) {
this.props.deleteSelectedWidget();
}
}}
/>
<Hotkey
global={true}
combo="del"
label="Delete Widget"
group="Canvas"
onKeyDown={(e: any) => {
if (this.stopPropagationIfWidgetSelected(e)) {
this.props.deleteSelectedWidget();
}
}}
/>
<Hotkey
global={true}
combo="mod + x"
label="Cut Widget"
group="Canvas"
onKeyDown={(e: any) => {
if (this.stopPropagationIfWidgetSelected(e)) {
this.props.cutSelectedWidget();
}
}}
/>
</Hotkeys>
);
}
public state = {
registered: false,
};
@ -173,6 +67,8 @@ class Editor extends Component<Props> {
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<Props> {
);
}
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 <Welcome />;
@ -216,7 +121,9 @@ class Editor extends Component<Props> {
<meta charSet="utf-8" />
<title>Editor | Appsmith</title>
</Helmet>
<MainContainer />
<GlobalHotKeys>
<MainContainer />
</GlobalHotKeys>
</div>
<ConfirmRunModal />
</DndProvider>
@ -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)),
};
};

View File

@ -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;

View File

@ -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: {

View File

@ -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 };
},

View File

@ -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<string>,
) => ({ ...state, query: action.payload }),
[ReduxActionTypes.TOGGLE_SHOW_GLOBAL_SEARCH_MODAL]: (
state: GlobalSearchReduxState,
) => ({ ...state, modalOpen: !state.modalOpen }),
[ReduxActionTypes.SET_RECENT_ENTITIES]: (
state: GlobalSearchReduxState,
action: ReduxAction<Array<RecentEntity>>,
) => ({
...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<RecentEntity>;
recentEntitiesRestored: boolean;
}
export default globalSearchReducer;

View File

@ -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;

View File

@ -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({

View File

@ -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<RecentEntity>) {
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<string>) {
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,
),
]);
}

View File

@ -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<InitializeEditorPayload>,
@ -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),
]);
}

View File

@ -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),
]);
}

View File

@ -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) =>

View File

@ -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],
[],
);
},
);

View File

@ -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("/");

View File

@ -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();
}
};

View File

@ -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],
),

View File

@ -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<string, any>;
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);
}
};

View File

@ -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"