diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/OtherUIFeatures/Omnibar_spec.js b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/OtherUIFeatures/Omnibar_spec.js index 2747f7219a..42cf832365 100644 --- a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/OtherUIFeatures/Omnibar_spec.js +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/OtherUIFeatures/Omnibar_spec.js @@ -3,7 +3,7 @@ const dsl = require("../../../../fixtures/omnibarDsl.json"); const commonlocators = require("../../../../locators/commonlocators.json"); import { ObjectsRegistry } from "../../../../support/Objects/Registry"; -const locators = ObjectsRegistry.CommonLocators; +const ee = ObjectsRegistry.EntityExplorer; describe("Omnibar functionality test cases", () => { const apiName = "Omnibar1"; @@ -81,31 +81,34 @@ describe("Omnibar functionality test cases", () => { "createNewJSCollection", ); cy.get(omnibar.categoryTitle).eq(1).click(); - // create new api, js object and cURL import from omnibar - cy.get(omnibar.createNew).eq(0).should("have.text", "New Blank API"); - // 2 is the index value of the JS Object in omnibar ui - cy.get(omnibar.createNew).eq(2).should("have.text", "New JS Object"); + // create new api, js object and cURL import from omnibar + + // 0 is the index value of the JS Object in omnibar ui + cy.get(omnibar.createNew).eq(0).should("have.text", "New JS Object"); + // 1 is the index value of the JS Object in omnibar ui + cy.get(omnibar.createNew).eq(1).should("have.text", "New Blank API"); // 3 is the index value of the Curl import in omnibar ui cy.get(omnibar.createNew).eq(3).should("have.text", "New cURL Import"); + cy.get(omnibar.createNew).eq(0).click(); cy.wait(1000); - cy.wait("@createNewApi"); - cy.renameWithInPane(apiName); - cy.get(omnibar.globalSearch).click({ force: true }); - cy.get(omnibar.categoryTitle).eq(1).click(); - // 2 is the index value of the JS Object in omnibar ui - cy.get(omnibar.createNew).eq(2).click(); - cy.wait(1000); cy.wait("@createNewJSCollection"); cy.wait(1000); cy.get(".t--js-action-name-edit-field").type(jsObjectName).wait(1000); cy.get(omnibar.globalSearch).click({ force: true }); cy.get(omnibar.categoryTitle).eq(1).click(); cy.wait(1000); - // 3 is the index value of the JS Object in omnibar ui - cy.get(omnibar.createNew).eq(3).click(); + + cy.get(omnibar.createNew).eq(1).click(); cy.wait(1000); + cy.wait("@createNewApi"); + cy.renameWithInPane(apiName); + cy.get(omnibar.globalSearch).click({ force: true }); + cy.get(omnibar.categoryTitle).eq(1).click(); + + cy.wait(1000); + cy.get(omnibar.createNew).eq(3).click(); cy.url().should("include", "curl-import?"); cy.get('p:contains("Import from CURL")').should("be.visible"); }); @@ -133,8 +136,7 @@ describe("Omnibar functionality test cases", () => { }); it("6. Verify Navigate section shows recently opened widgets and datasources", function () { - cy.get(".bp3-icon-chevron-left").click({ force: true }); - cy.openPropertyPane("buttonwidget"); + ee.SelectEntityByName("Button1", "Widgets"); cy.get(omnibar.globalSearch).click({ force: true }); cy.get(omnibar.categoryTitle).eq(0).click(); // verify recently opened items with their subtext i.e page name @@ -146,13 +148,13 @@ describe("Omnibar functionality test cases", () => { cy.xpath(omnibar.recentlyopenItem) .eq(1) - .should("have.text", "Omnibar2") + .should("have.text", "Omnibar1") .next() .should("have.text", "Page1"); cy.xpath(omnibar.recentlyopenItem) .eq(2) - .should("have.text", "Omnibar1") + .should("have.text", "Omnibar2") .next() .should("have.text", "Page1"); diff --git a/app/client/cypress/support/Pages/EntityExplorer.ts b/app/client/cypress/support/Pages/EntityExplorer.ts index e2851e3c7c..4982c51141 100644 --- a/app/client/cypress/support/Pages/EntityExplorer.ts +++ b/app/client/cypress/support/Pages/EntityExplorer.ts @@ -234,9 +234,13 @@ export class EntityExplorer { public CreateNewDsQuery(dsName: string, isQuery = true) { cy.get(this.locator._createNew).last().click({ force: true }); - let overlayItem = isQuery - ? this._visibleTextSpan(dsName + " Query") - : this._visibleTextSpan(dsName); + const searchText = isQuery ? dsName + " query" : dsName; + this.SearchAndClickOmnibar(searchText); + } + + public SearchAndClickOmnibar(searchText: string) { + cy.get(`[data-testId="t--search-file-operation"]`).type(searchText); + let overlayItem = this._visibleTextSpan(searchText); this.agHelper.GetNClick(overlayItem); } diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js index 95dc602846..01eafa9224 100644 --- a/app/client/cypress/support/commands.js +++ b/app/client/cypress/support/commands.js @@ -706,8 +706,8 @@ Cypress.Commands.add("NavigateToWidgetsInExplorer", () => { Cypress.Commands.add("NavigateToJSEditor", () => { cy.get(explorer.createNew).click({ force: true }); - // 2 is the index value of the JS Object in omnibar ui - cy.get(".t--file-operation").eq(2).click({ force: true }); + cy.get(`[data-testId="t--search-file-operation"]`).type("New JS Object"); + cy.get(".t--file-operation").eq(0).click({ force: true }); }); Cypress.Commands.add("importCurl", () => { diff --git a/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.test.ts b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.test.ts new file mode 100644 index 0000000000..6a22227a48 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.test.ts @@ -0,0 +1,249 @@ +import { getFilteredAndSortedFileOperations } from "./GlobalSearchHooks"; +import type { Datasource } from "entities/Datasource"; +import { SEARCH_ITEM_TYPES } from "./utils"; + +describe("getFilteredAndSortedFileOperations", () => { + it("works without any datasources", () => { + const fileOptions = getFilteredAndSortedFileOperations(""); + + expect(fileOptions[0]).toEqual( + expect.objectContaining({ + title: "New JS Object", + }), + ); + + expect(fileOptions[1]).toEqual( + expect.objectContaining({ + title: "New Blank API", + }), + ); + + expect(fileOptions[2]).toEqual( + expect.objectContaining({ + title: "New Blank GraphQL API", + }), + ); + + expect(fileOptions[3]).toEqual( + expect.objectContaining({ + title: "New cURL Import", + }), + ); + + expect(fileOptions[4]).toEqual( + expect.objectContaining({ + title: "New Datasource", + }), + ); + }); + it("works without permissions", () => { + const actionOperationsWithoutCreate = getFilteredAndSortedFileOperations( + "", + [], + [], + {}, + false, + ); + + expect(actionOperationsWithoutCreate.length).toEqual(0); + + const actionOperationsWithoutDatasourcePermission = + getFilteredAndSortedFileOperations("", [], [], {}, true, false); + + expect(actionOperationsWithoutDatasourcePermission.length).toEqual(4); + }); + + it("shows app datasources before other datasources", () => { + const appDatasource: Datasource = { + datasourceConfiguration: { + url: "", + }, + id: "", + isValid: true, + pluginId: "", + workspaceId: "", + name: "App datasource", + }; + + const otherDatasource: Datasource = { + datasourceConfiguration: { + url: "", + }, + id: "", + isValid: false, + pluginId: "", + workspaceId: "", + name: "Other datasource", + }; + + const fileOptions = getFilteredAndSortedFileOperations( + "", + [appDatasource], + [otherDatasource], + {}, + true, + true, + ); + + expect(fileOptions[0]).toEqual( + expect.objectContaining({ + title: "New JS Object", + }), + ); + + expect(fileOptions[1]).toEqual( + expect.objectContaining({ + title: "CREATE A QUERY", + kind: SEARCH_ITEM_TYPES.sectionTitle, + }), + ); + + expect(fileOptions[2]).toEqual( + expect.objectContaining({ + title: "New App datasource query", + }), + ); + + expect(fileOptions[3]).toEqual( + expect.objectContaining({ + title: "New Other datasource query", + }), + ); + }); + + it("sorts datasources based on recency", () => { + const appDatasource: Datasource = { + datasourceConfiguration: { + url: "", + }, + id: "123", + isValid: true, + pluginId: "", + workspaceId: "", + name: "App datasource", + }; + + const otherDatasource: Datasource = { + datasourceConfiguration: { + url: "", + }, + id: "abc", + isValid: false, + pluginId: "", + workspaceId: "", + name: "Other datasource", + }; + + const fileOptions = getFilteredAndSortedFileOperations( + "", + [appDatasource], + [otherDatasource], + { abc: 1, "123": 3 }, + true, + true, + ); + + expect(fileOptions[0]).toEqual( + expect.objectContaining({ + title: "New JS Object", + }), + ); + + expect(fileOptions[1]).toEqual( + expect.objectContaining({ + title: "CREATE A QUERY", + kind: SEARCH_ITEM_TYPES.sectionTitle, + }), + ); + + expect(fileOptions[2]).toEqual( + expect.objectContaining({ + title: "New Other datasource query", + }), + ); + + expect(fileOptions[3]).toEqual( + expect.objectContaining({ + title: "New App datasource query", + }), + ); + }); + + it("filters with a query", () => { + const appDatasource: Datasource = { + datasourceConfiguration: { + url: "", + }, + id: "", + isValid: true, + pluginId: "", + workspaceId: "", + name: "App datasource", + }; + + const otherDatasource: Datasource = { + datasourceConfiguration: { + url: "", + }, + id: "", + isValid: false, + pluginId: "", + workspaceId: "", + name: "Other datasource", + }; + + const fileOptions = getFilteredAndSortedFileOperations( + "App", + [appDatasource], + [otherDatasource], + {}, + true, + true, + ); + + expect(fileOptions[0]).toEqual( + expect.objectContaining({ + title: "New App datasource query", + }), + ); + }); + + it("Non matching query shows on datasource creation", () => { + const appDatasource: Datasource = { + datasourceConfiguration: { + url: "", + }, + id: "", + isValid: true, + pluginId: "", + workspaceId: "", + name: "App datasource", + }; + + const otherDatasource: Datasource = { + datasourceConfiguration: { + url: "", + }, + id: "", + isValid: false, + pluginId: "", + workspaceId: "", + name: "Other datasource", + }; + + const fileOptions = getFilteredAndSortedFileOperations( + "zzzz", + [appDatasource], + [otherDatasource], + {}, + true, + true, + ); + + expect(fileOptions[0]).toEqual( + expect.objectContaining({ + title: "New Datasource", + }), + ); + }); +}); diff --git a/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.tsx b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.tsx index c46eb85de3..e3df5e24a1 100644 --- a/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.tsx +++ b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.tsx @@ -10,10 +10,12 @@ import { getAllPageWidgets, getJSCollections, getPlugins, + getRecentDatasourceIds, } from "selectors/entitiesSelector"; import { useSelector } from "react-redux"; import type { EventLocation } from "utils/AnalyticsUtil"; import history from "utils/history"; +import type { ActionOperation } from "./utils"; import { actionOperations, attachKind, @@ -35,9 +37,18 @@ import { getCurrentAppWorkspace } from "@appsmith/selectors/workspaceSelectors"; export const useFilteredFileOperations = (query = "") => { const { appWideDS = [], otherDS = [] } = useAppWideAndOtherDatasource(); + const recentDatasourceIds = useSelector(getRecentDatasourceIds); + // helper map for sorting based on recent usage + const recentlyUsedOrderMap = recentDatasourceIds.reduce( + (map: Record, id, index) => { + map[id] = index; + return map; + }, + {}, + ); /** * Work around to get the rest api cloud image. - * We don't have it store as an svg + * We don't have it store as a svg */ const plugins = useSelector(getPlugins); const restApiPlugin = plugins.find( @@ -62,101 +73,118 @@ export const useFilteredFileOperations = (query = "") => { userWorkspacePermissions, ); - return useMemo(() => { - let fileOperations: any = - (canCreateActions && - actionOperations.filter((op) => - op.title.toLowerCase().includes(query.toLowerCase()), - )) || - []; - const filteredAppWideDS = appWideDS.filter((ds: Datasource) => - ds.name.toLowerCase().includes(query.toLowerCase()), - ); - const otherFilteredDS = otherDS.filter((ds: Datasource) => - ds.name.toLowerCase().includes(query.toLowerCase()), + return useMemo( + () => + getFilteredAndSortedFileOperations( + query, + appWideDS, + otherDS, + recentlyUsedOrderMap, + canCreateActions, + canCreateDatasource, + pagePermissions, + ), + [query, appWideDS, otherDS], + ); +}; + +export const getFilteredAndSortedFileOperations = ( + query: string, + appWideDS: Datasource[] = [], + otherDS: Datasource[] = [], + recentlyUsedOrderMap: Record = {}, + canCreateActions = true, + canCreateDatasource = true, + pagePermissions: string[] = [], +) => { + const fileOperations: ActionOperation[] = []; + if (!canCreateActions) return fileOperations; + + // Add JS object operation + fileOperations.push(actionOperations[2]); + // Add app datasources + if (appWideDS.length > 0 || otherDS.length > 0) { + const showCreateQuery = [...appWideDS, ...otherDS].some((ds: Datasource) => + hasCreateDatasourceActionPermission([ + ...(ds.userPermissions ?? []), + ...pagePermissions, + ]), ); - if (filteredAppWideDS.length > 0 || otherFilteredDS.length > 0) { - const showCreateQuery = [...filteredAppWideDS, ...otherFilteredDS].some( - (ds: Datasource) => - hasCreateDatasourceActionPermission([ - ...(ds.userPermissions ?? []), - ...pagePermissions, - ]), - ); + if (showCreateQuery) { + fileOperations.push({ + desc: "", + title: "CREATE A QUERY", + kind: SEARCH_ITEM_TYPES.sectionTitle, + }); + } + } - fileOperations = [ - ...fileOperations, - showCreateQuery && { - title: "CREATE A QUERY", - kind: SEARCH_ITEM_TYPES.sectionTitle, - }, - ]; + // get all datasources, app ds listed first + const datasources = [...appWideDS, ...otherDS].filter((ds) => + hasCreateDatasourceActionPermission([ + ...(ds.userPermissions ?? []), + ...pagePermissions, + ]), + ); + + // Sort datasources based on recency + datasources.sort((a, b) => { + const orderA = recentlyUsedOrderMap[a.id]; + const orderB = recentlyUsedOrderMap[b.id]; + if (orderA !== undefined && orderB !== undefined) { + return orderA - orderB; + } else if (orderA !== undefined) { + return -1; + } else if (orderB !== undefined) { + return 1; + } else { + return 0; } - if (filteredAppWideDS.length > 0) { - fileOperations = [ - ...fileOperations, - ...filteredAppWideDS.map((ds) => { - return hasCreateDatasourceActionPermission([ - ...(ds.userPermissions ?? []), - ...pagePermissions, - ]) - ? { - title: `New ${ds.name} Query`, - shortTitle: `${ds.name} Query`, - desc: `Create a query in ${ds.name}`, - pluginId: ds.pluginId, - kind: SEARCH_ITEM_TYPES.actionOperation, - action: (pageId: string, from: EventLocation) => - createNewQueryAction(pageId, from, ds.id), - } - : null; - }), - ]; - } - if (otherFilteredDS.length > 0) { - fileOperations = [ - ...fileOperations, - ...otherFilteredDS.map((ds) => { - return hasCreateDatasourceActionPermission([ - ...(ds.userPermissions ?? []), - ...pagePermissions, - ]) - ? { - title: `New ${ds.name} Query`, - shortTitle: `${ds.name} Query`, - desc: `Create a query in ${ds.name}`, - kind: SEARCH_ITEM_TYPES.actionOperation, - pluginId: ds.pluginId, - action: (pageId: string, from: EventLocation) => - createNewQueryAction(pageId, from, ds.id), - } - : null; - }), - ]; - } - fileOperations = [ - ...fileOperations, - canCreateDatasource && { - title: "New Datasource", - icon: ( - - - - ), - kind: SEARCH_ITEM_TYPES.actionOperation, - redirect: (pageId: string) => { - history.push( - integrationEditorURL({ - pageId, - selectedTab: INTEGRATION_TABS.NEW, - }), - ); - }, + }); + + // map into operations + const dsOperations = datasources.map((ds) => ({ + title: `New ${ds.name} query`, + shortTitle: `${ds.name} query`, + desc: `Create a query in ${ds.name}`, + pluginId: ds.pluginId, + kind: SEARCH_ITEM_TYPES.actionOperation, + action: (pageId: string, from: EventLocation) => + createNewQueryAction(pageId, from, ds.id), + })); + fileOperations.push(...dsOperations); + + // Add generic action creation + fileOperations.push( + ...actionOperations.filter((op) => op.title !== actionOperations[2].title), + ); + // Filter out based on query + const filteredFileOperations = fileOperations + .filter(Boolean) + .filter((ds) => ds.title.toLowerCase().includes(query.toLowerCase())); + // Add genetic datasource creation + if (canCreateDatasource) { + filteredFileOperations.push({ + desc: "Create a new datasource in the organisation", + title: "New Datasource", + icon: ( + + + + ), + kind: SEARCH_ITEM_TYPES.actionOperation, + redirect: (pageId: string) => { + history.push( + integrationEditorURL({ + pageId, + selectedTab: INTEGRATION_TABS.NEW, + }), + ); }, - ]; - return fileOperations.filter(Boolean); - }, [query, appWideDS, otherDS]); + }); + } + return filteredFileOperations; }; export const useFilteredWidgets = (query: string) => { diff --git a/app/client/src/pages/Editor/Explorer/Files/Submenu.tsx b/app/client/src/pages/Editor/Explorer/Files/Submenu.tsx index a0cf65c7e2..ffd903d9b0 100644 --- a/app/client/src/pages/Editor/Explorer/Files/Submenu.tsx +++ b/app/client/src/pages/Editor/Explorer/Files/Submenu.tsx @@ -160,6 +160,7 @@ export default function ExplorerSubMenu({ autoComplete="off" autoFocus className="flex-grow text-sm py-2 text-gray-800 bg-transparent placeholder-trueGray-500" + data-testId="t--search-file-operation" onChange={onChange} placeholder="Search datasources" type="text" diff --git a/app/client/src/reducers/entityReducers/datasourceReducer.ts b/app/client/src/reducers/entityReducers/datasourceReducer.ts index be35cc4f85..c5b0cb9bbe 100644 --- a/app/client/src/reducers/entityReducers/datasourceReducer.ts +++ b/app/client/src/reducers/entityReducers/datasourceReducer.ts @@ -38,6 +38,7 @@ export interface DatasourceDataState { isFetchingSheets: boolean; isFetchingColumns: boolean; }; + recentDatasources: string[]; } const initialState: DatasourceDataState = { @@ -65,6 +66,7 @@ const initialState: DatasourceDataState = { isFetchingSheets: false, isFetchingColumns: false, }, + recentDatasources: [], }; const datasourceReducer = createReducer(initialState, { @@ -271,6 +273,7 @@ const datasourceReducer = createReducer(initialState, { list: state.list.concat(action.payload), isDatasourceBeingSaved: false, isDatasourceBeingSavedFromPopup: false, + recentDatasources: [action.payload.id, ...state.recentDatasources], }; }, [ReduxActionTypes.UPDATE_DATASOURCE_SUCCESS]: ( @@ -290,6 +293,10 @@ const datasourceReducer = createReducer(initialState, { return datasource; }), + recentDatasources: [ + action.payload.id, + ...state.recentDatasources.filter((ds) => ds !== action.payload.id), + ], }; }, [ReduxActionTypes.UPDATE_DATASOURCE_IMPORT_SUCCESS]: ( diff --git a/app/client/src/selectors/entitiesSelector.ts b/app/client/src/selectors/entitiesSelector.ts index ef2f652083..d90a401cf7 100644 --- a/app/client/src/selectors/entitiesSelector.ts +++ b/app/client/src/selectors/entitiesSelector.ts @@ -48,6 +48,10 @@ export const getDatasources = (state: AppState): Datasource[] => { return state.entities.datasources.list; }; +export const getRecentDatasourceIds = (state: AppState): string[] => { + return state.entities.datasources.recentDatasources; +}; + export const getDatasourcesStructure = ( state: AppState, ): Record => {