feat: Update order of action file operations (#22754)

## Description

In order to improve new user experience, we want to update the order of
items listed in the new action list. This will show app datasources
higher in the order and generic creation action lower in the order.
Create JS objects is still listed on the top.
This also will update the list sorting on the omni bar.

<img width="524" alt="Screenshot 2023-04-26 at 3 49 31 PM"
src="https://user-images.githubusercontent.com/12022471/234547215-c15c8f12-7be1-4462-8b78-190e7dc75dea.png">

<img width="513" alt="Screenshot 2023-04-26 at 3 53 39 PM"
src="https://user-images.githubusercontent.com/12022471/234547574-46308912-28de-49f7-a3bf-f872def42adb.png">


> Improve the order of action create list

Fixes #22618

## Type of change

- New feature (non-breaking change which adds functionality)


## How Has This Been Tested?

Extracted the function and adding some jest cases for the functionality 

- Manual
- Jest
- Cypress

### Test Plan
TBA

### Issues raised during DP testing

https://github.com/appsmithorg/appsmith/pull/22754#issuecomment-1527503666


## Checklist:
### Dev activity
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


### QA activity:
- [ ] Test plan has been approved by relevant developers
- [x] Test plan has been peer reviewed by QA
- [ ] Cypress test cases have been added and approved by either SDET or
manual QA
- [ ] Organized project review call with relevant stakeholders after
Round 1/2 of QA
- [x] Added Test Plan Approved label after reveiwing all Cypress test
This commit is contained in:
Hetu Nandu 2023-05-08 14:43:23 +05:30 committed by GitHub
parent 1c56186b53
commit a4dec4bb6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 409 additions and 114 deletions

View File

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

View File

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

View File

@ -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", () => {

View File

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

View File

@ -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<string, number>, 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<string, number> = {},
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: (
<EntityIcon>
<AddLineIcon size={22} />
</EntityIcon>
),
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: (
<EntityIcon>
<AddLineIcon size={22} />
</EntityIcon>
),
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) => {

View File

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

View File

@ -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]: (

View File

@ -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<string, DatasourceStructure> => {