Omnibar global search (#2903)
This commit is contained in:
parent
2dfc8ebf8a
commit
99b3fa6bb8
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
34
app/client/src/actions/globalSearchActions.ts
Normal file
34
app/client/src/actions/globalSearchActions.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -14,3 +14,11 @@ export const initEditor = (
|
|||
pageId,
|
||||
},
|
||||
});
|
||||
|
||||
export const resetEditorRequest = () => ({
|
||||
type: ReduxActionTypes.RESET_EDITOR_REQUEST,
|
||||
});
|
||||
|
||||
export const resetEditorSuccess = () => ({
|
||||
type: ReduxActionTypes.RESET_EDITOR_SUCCESS,
|
||||
});
|
||||
|
|
|
|||
6
app/client/src/actions/recentEntityActions.ts
Normal file
6
app/client/src/actions/recentEntityActions.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { ReduxActionTypes } from "constants/ReduxActionConstants";
|
||||
|
||||
export const handlePathUpdated = (pathName: string) => ({
|
||||
type: ReduxActionTypes.HANDLE_PATH_UPDATED,
|
||||
payload: { pathName },
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
13
app/client/src/assets/icons/ads/docs.svg
Normal file
13
app/client/src/assets/icons/ads/docs.svg
Normal 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 |
3
app/client/src/assets/icons/ads/entities.svg
Normal file
3
app/client/src/assets/icons/ads/entities.svg
Normal 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 |
4
app/client/src/assets/icons/ads/link.svg
Normal file
4
app/client/src/assets/icons/ads/link.svg
Normal 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 |
4
app/client/src/assets/icons/ads/recent.svg
Normal file
4
app/client/src/assets/icons/ads/recent.svg
Normal 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 |
BIN
app/client/src/assets/images/no_search_data.png
Normal file
BIN
app/client/src/assets/images/no_search_data.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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" }}
|
||||
>
|
||||
✨ Press <StyledKey>↵</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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>⌘</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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 & 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
`<${algoliaHighlightTag}>|</${algoliaHighlightTag}>`,
|
||||
"g",
|
||||
);
|
||||
Array.from(documentObj.querySelectorAll("code")).forEach((match) => {
|
||||
match.innerHTML = match.innerHTML.replace(aisTag, "");
|
||||
});
|
||||
|
||||
// update link hrefs and target
|
||||
const aisTagEncoded = new RegExp(
|
||||
`%3C${algoliaHighlightTag}%3E|%3C/${algoliaHighlightTag}%3E`,
|
||||
"g",
|
||||
);
|
||||
|
||||
Array.from(documentObj.querySelectorAll("a")).forEach((match) => {
|
||||
match.target = "_blank";
|
||||
try {
|
||||
const hrefURL = new URL(match.href);
|
||||
const isRelativeURL = hrefURL.hostname === window.location.hostname;
|
||||
match.href = !isRelativeURL
|
||||
? match.href
|
||||
: `${HelpBaseURL}/${match.getAttribute("href")}`;
|
||||
match.href = match.href.replace(aisTagEncoded, "");
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// update description title
|
||||
updateDocumentDescriptionTitle(documentObj, item);
|
||||
|
||||
const content = strip(documentObj.body.innerHTML).trim();
|
||||
return content;
|
||||
} catch (e) {
|
||||
console.log(e, "err");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export default parseDocumentationContent;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
}));
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
156
app/client/src/pages/Editor/GlobalHotKeys.tsx
Normal file
156
app/client/src/pages/Editor/GlobalHotKeys.tsx
Normal 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);
|
||||
64
app/client/src/pages/Editor/HelpButton.tsx
Normal file
64
app/client/src/pages/Editor/HelpButton.tsx
Normal 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;
|
||||
39
app/client/src/pages/Editor/PropertyPaneHelpButton.tsx
Normal file
39
app/client/src/pages/Editor/PropertyPaneHelpButton.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
|
|
|
|||
49
app/client/src/reducers/uiReducers/globalSearchReducer.ts
Normal file
49
app/client/src/reducers/uiReducers/globalSearchReducer.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
89
app/client/src/sagas/GlobalSearchSagas.ts
Normal file
89
app/client/src/sagas/GlobalSearchSagas.ts
Normal 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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
75
app/client/src/sagas/RecentEntitiesSagas.ts
Normal file
75
app/client/src/sagas/RecentEntitiesSagas.ts
Normal 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),
|
||||
]);
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
[],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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("/");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user