Merge branch 'release' into fix/disable-cancel-button-on-a-disabled-select-widget

This commit is contained in:
Tolulope Adetula 2022-04-29 06:12:19 +01:00
commit fa48a026ad
112 changed files with 3396 additions and 806 deletions

View File

@ -459,7 +459,7 @@ jobs:
# In case this is second attempt try restoring status of the prior attempt from cache
- name: Restore the previous run result
uses: martijnhols/actions-cache@v3
uses: martijnhols/actions-cache@v3.0.2
with:
path: |
~/run_result

View File

@ -471,7 +471,7 @@ jobs:
# In case this is second attempt try restoring status of the prior attempt from cache
- name: Restore the previous run result
uses: martijnhols/actions-cache@v3
uses: martijnhols/actions-cache@v3.0.2
with:
path: |
~/run_result
@ -806,7 +806,7 @@ jobs:
# In case this is second attempt try restoring status of the prior attempt from cache
- name: Restore the previous run result
uses: martijnhols/actions-cache@v3
uses: martijnhols/actions-cache@v3.0.2
with:
path: |
~/run_result
@ -1218,12 +1218,11 @@ jobs:
summary: "https://github.com/" + process.env.repository + "/actions/runs/" + process.env.run_id
}
});
console.log({ result });
return result;
} catch(e) {
console.error({ error: e.message });
}
console.log({ result });
return result;
}
- name: Dump the client payload context

View File

@ -446,7 +446,7 @@ jobs:
# In case this is second attempt try restoring status of the prior attempt from cache
- name: Restore the previous run result
uses: martijnhols/actions-cache@v3
uses: martijnhols/actions-cache@v3.0.2
with:
path: |
~/run_result
@ -785,7 +785,7 @@ jobs:
# In case this is second attempt try restoring status of the prior attempt from cache
- name: Restore the previous run result
uses: martijnhols/actions-cache@v3
uses: martijnhols/actions-cache@v3.0.2
with:
path: |
~/run_result

View File

@ -455,7 +455,7 @@ jobs:
# In case this is second attempt try restoring status of the prior attempt from cache
- name: Restore the previous run result
uses: martijnhols/actions-cache@v3
uses: martijnhols/actions-cache@v3.0.2
with:
path: |
~/run_result
@ -829,7 +829,7 @@ jobs:
# In case this is second attempt try restoring status of the prior attempt from cache
- name: Restore the previous run result
uses: martijnhols/actions-cache@v3
uses: martijnhols/actions-cache@v3.0.2
with:
path: |
~/run_result

View File

@ -16,18 +16,15 @@ RUN apt-get update \
supervisor curl cron certbot nginx gnupg wget netcat openssh-client \
software-properties-common gettext openjdk-11-jre \
python3-pip python-setuptools git \
&& add-apt-repository ppa:redislabs/redis \
&& pip install --no-cache-dir git+https://github.com/coderanger/supervisor-stdout@973ba19967cdaf46d9c1634d1675fc65b9574f6e \
&& apt-get remove -y git python3-pip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
&& apt-get remove -y git python3-pip
# Install MongoDB v4.0.5, Redis, NodeJS - Service Layer
RUN wget -qO - https://www.mongodb.org/static/pgp/server-4.4.asc | apt-key add -
RUN echo "deb [ arch=amd64,arm64 ]http://repo.mongodb.org/apt/ubuntu focal/mongodb-org/4.4 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-4.4.list \
&& apt-get remove wget -y
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - \
&& apt-get -y install --no-install-recommends -y mongodb-org=4.4.6 nodejs redis build-essential \
&& apt-get install --no-install-recommends -y mongodb-org=4.4.6 nodejs redis build-essential \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@ -88,6 +85,9 @@ RUN chmod 0644 /etc/cron.d/*
RUN chmod +x entrypoint.sh renew-certificate.sh
# Disable setuid/setgid bits for the files inside container.
RUN find / \( -path /proc -prune \) -o \( \( -perm -2000 -o -perm -4000 \) -print -exec chmod -s '{}' + \) || true
# Update path to load appsmith utils tool as default
ENV PATH /opt/appsmith/utils/node_modules/.bin:$PATH

View File

@ -1,4 +0,0 @@
{
"githubClientId": "",
"githubClientSecret": ""
}

View File

@ -25,17 +25,27 @@ describe("AForce - Community Issues page validations", function () {
let reconnect = true, selectedRow: number;
it("1. Import application json and validate headers", () => {
homePage.ImportApp("AForceMigrationExport.json", reconnect)
if (reconnect)
dataSources.ReconnectDataSourcePostgres("AForceDB")
//Validate table is not empty!
table.WaitUntilTableLoad()
//Validating order of header columns!
table.AssertTableHeaderOrder("TypeTitleStatus+1CommentorsVotesAnswerUpVoteStatesupvote_ididgithub_issue_idauthorcreated_atdescriptionlabelsstatelinkupdated_at")
//Validating hidden columns:
table.AssertHiddenColumns(['States', 'upvote_id', 'id', 'github_issue_id', 'author', 'created_at', 'description', 'labels', 'state', 'link', 'updated_at'])
cy.visit("/applications");
homePage.ImportApp("AForceMigrationExport.json", reconnect);
cy.wait("@importNewApplication").then((interception) => {
cy.wait(100);
const { isPartialImport } = interception.response.body.data;
if (isPartialImport) {
// should reconnect modal
dataSources.ReconnectDataSourcePostgres("AForceDB")
} else {
cy.get(homePage.toastMessage).should(
"contain",
"Application imported successfully",
);
}
//Validate table is not empty!
table.WaitUntilTableLoad()
//Validating order of header columns!
table.AssertTableHeaderOrder("TypeTitleStatus+1CommentorsVotesAnswerUpVoteStatesupvote_ididgithub_issue_idauthorcreated_atdescriptionlabelsstatelinkupdated_at")
//Validating hidden columns:
table.AssertHiddenColumns(['States', 'upvote_id', 'id', 'github_issue_id', 'author', 'created_at', 'description', 'labels', 'state', 'link', 'updated_at'])
});
});
it("2. Validate table navigation with Server Side pagination enabled with Default selected row", () => {

View File

@ -6,7 +6,7 @@ describe("Reconnect Datasource Modal validation while importing application", fu
let appid;
let newOrganizationName;
let appName;
it("Import application from json with one postgres", function() {
it("Import application from json with one postgres and success modal", function() {
cy.NavigateToHome();
// import application
cy.generateUUID().then((uid) => {
@ -49,16 +49,32 @@ describe("Reconnect Datasource Modal validation while importing application", fu
cy.get(
"[data-cy='datasourceConfiguration.connection.ssl.authType']",
).should("contain", "Default");
cy.get(reconnectDatasourceModal.SkipToAppBtn).click({
force: true,
});
cy.ReconnectDatasource("Untitled Datasource");
cy.wait(1000);
cy.fillPostgresDatasourceForm();
cy.testSaveDatasource();
cy.wait(2000);
// cy.get(reconnectDatasourceModal.SkipToAppBtn).click({
// force: true,
// });
// cy.wait(2000);
} else {
cy.get(homePage.toastMessage).should(
"contain",
"Application imported successfully",
);
}
// check datasource configured success modal
cy.get(".t--import-app-success-modal").should("be.visible");
cy.get(".t--import-app-success-modal").should(
"contain",
"All your datasources are configuered and ready to use.",
);
cy.get(".t--import-success-modal-got-it").click({ force: true });
cy.get(".t--import-app-success-modal").should("not.exist");
const uuid = () => Cypress._.random(0, 1e4);
const name = uuid();
appName = `app${name}`;

View File

@ -73,6 +73,11 @@ describe("Test Create Api and Bind to Table widget", function() {
cy.wait(500);
cy.wait("@postExecute");
cy.wait(500);
cy.get(".show-page-items").should("contain", "20 Records");
cy.get(".page-item")
.next()
.should("contain", "of 2");
cy.get(".t--table-widget-next-page").should("not.have.attr", "disabled");
cy.ValidateTableData("1");

View File

@ -19,7 +19,7 @@ describe("JSON Form Widget Field Change", () => {
cy.get(`${fieldPrefix}-name`)
.find("button")
.should("have.length", 2);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
@ -36,7 +36,7 @@ describe("JSON Form Widget Field Change", () => {
.find("input")
.invoke("attr", "type")
.should("contain", "checkbox");
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
@ -53,7 +53,7 @@ describe("JSON Form Widget Field Change", () => {
.find("input")
.click({ force: true });
cy.get(".bp3-popover.bp3-dateinput-popover").should("exist");
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
@ -71,7 +71,7 @@ describe("JSON Form Widget Field Change", () => {
.find(".bp3-control.bp3-switch")
.should("exist");
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
@ -82,12 +82,12 @@ describe("JSON Form Widget Field Change", () => {
cy.get(".bp3-select-popover.select-popover-wrapper").should("not.exist");
cy.openFieldConfiguration("name");
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Select$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Select/);
cy.get(`${fieldPrefix}-name label`).click({ force: true });
cy.get(".bp3-select-popover.select-popover-wrapper").should("exist");
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
@ -104,7 +104,7 @@ describe("JSON Form Widget Field Change", () => {
.find(".rc-select-multiple")
.should("exist");
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
@ -122,7 +122,7 @@ describe("JSON Form Widget Field Change", () => {
.should("exist")
.should("have.length", 2);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
@ -139,7 +139,7 @@ describe("JSON Form Widget Field Change", () => {
.find(".t--jsonformfield-array-add-btn")
.should("exist");
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
@ -160,7 +160,7 @@ describe("JSON Form Widget Field Change", () => {
.find("input")
.should("exist");
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
@ -185,7 +185,7 @@ describe("JSON Form Widget Field Change", () => {
.should("have.length", 2);
});
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Text Input/);
cy.closePropertyPane();
});
});

View File

@ -166,7 +166,7 @@ describe("Select Field Property Control", () => {
cy.openPropertyPane("jsonformwidget");
cy.testJsontext("sourcedata", JSON.stringify(schema));
cy.openFieldConfiguration("state");
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Select$/);
cy.selectDropdownValue(commonlocators.jsonFormFieldType, /^Select/);
});
it("has valid default value", () => {

View File

@ -2,6 +2,7 @@ const pages = require("../../../../locators/Pages.json");
const generatePage = require("../../../../locators/GeneratePage.json");
import homePage from "../../../../locators/HomePage";
const datasource = require("../../../../locators/DatasourcesEditor.json");
const commonlocators = require("../../../../locators/commonlocators.json");
describe("Generate New CRUD Page Inside from Mongo as Data Source", function() {
let datasourceName;
@ -91,6 +92,9 @@ describe("Generate New CRUD Page Inside from Mongo as Data Source", function() {
"response.body.responseMeta.status",
200,
);
cy.get(commonlocators.toastAction)
.should("have.length", 1)
.should("have.text", "Successfully generated a page");
cy.get("span:contains('GOT IT')").click();
});

View File

@ -21,7 +21,7 @@ describe("Git import flow", function() {
});
});
it("Import an app from JSON with Postgres, MySQL, Mongo db", () => {
cy.get(homePage.homeIcon).click();
cy.NavigateToHome();
cy.get(homePage.optionsIcon)
.first()
.click();
@ -54,6 +54,10 @@ describe("Git import flow", function() {
"contain",
"Application imported successfully",
); */
cy.get(reconnectDatasourceModal.ImportSuccessModal).should("be.visible");
cy.get(reconnectDatasourceModal.ImportSuccessModalCloseBtn).click({
force: true,
});
cy.wait(1000);
cy.generateUUID().then((uid) => {
repoName = uid;
@ -98,6 +102,11 @@ describe("Git import flow", function() {
cy.get(datasourceEditor.sectionAuthentication).click();
cy.testSaveDatasource();
cy.wait(2000);
cy.get(reconnectDatasourceModal.ImportSuccessModal).should("be.visible");
cy.get(reconnectDatasourceModal.ImportSuccessModalCloseBtn).click({
force: true,
});
cy.wait(1000);
/* cy.get(homePage.toastMessage).should(
"contain",
"Application imported successfully",

View File

@ -0,0 +1,42 @@
const dsl = require("../../../../fixtures/jsonFormDslWithSchema.json");
const { ObjectsRegistry } = require("../../../../support/Objects/Registry");
let ee = ObjectsRegistry.EntityExplorer;
describe("Property pane js enabled field", function() {
before(() => {
cy.addDsl(dsl);
});
it("Ensure text is visible for js enabled field when a section is collapsed by default", function() {
cy.openPropertyPane("jsonformwidget");
cy.get(".t--property-pane-section-collapse-submitbuttonstyles").click();
cy.get(".t--property-control-buttonvariant")
.find(".t--js-toggle")
.first()
.click();
cy.get(".t--property-control-buttonvariant")
.find(".t--js-toggle")
.first()
.should("have.class", "is-active");
cy.get(".t--property-control-buttonvariant .CodeMirror-code").type(
"PRIMARY",
);
cy.get(".t--property-control-buttonvariant")
.find(".CodeMirror-code")
.invoke("text")
.should("equal", "PRIMARY");
cy.closePropertyPane();
cy.wait(1000);
cy.openPropertyPane("jsonformwidget");
cy.get(".t--property-pane-section-collapse-submitbuttonstyles").click();
cy.get(".t--property-control-buttonvariant")
.find(".CodeMirror-code")
.invoke("text")
.should("equal", "PRIMARY");
});
});

View File

@ -131,15 +131,15 @@ describe("Undo/Redo functionality", function() {
.first()
.focus()
.type("{downarrow}{downarrow}{downarrow} ")
.type("test:()=>{},");
.type("testJSFunction:()=>{},");
cy.get("body").type(`{${modifierKey}}z{${modifierKey}}z{${modifierKey}}z`);
// verifying test function is not visible in response tab after undo
cy.get(".function-name").should("not.contain.text", "test");
// verifying testJSFunction is not visible on page after undo
cy.contains("testJSFunction").should("not.exist");
cy.get("body").type(
`{${modifierKey}}{shift}z{${modifierKey}}{shift}z{${modifierKey}}{shift}z`,
);
// verifying test function is visible in response tab after redo
cy.get(".function-name").should("contain.text", "test");
// verifying testJSFunction is visible on page after redo
cy.contains("testJSFunction").should("exist");
// performing undo from app menu
cy.get(".t--application-name").click({ force: true });
cy.get("li:contains(Edit)")

View File

@ -1,6 +1,6 @@
import { ObjectsRegistry } from "../../../../support/Objects/Registry";
let guid: any, jsName : any;
let guid: any, jsName: any;
const agHelper = ObjectsRegistry.AggregateHelper,
ee = ObjectsRegistry.EntityExplorer,
dataSources = ObjectsRegistry.DataSources,
@ -24,7 +24,7 @@ describe("JSObjects OnLoad Actions tests", function() {
true,
false,
);
jsEditor.EnableOnPageLoad("getId", false, true);
jsEditor.AddJSFunctionSettings("getId", false, true);
agHelper.GenerateUUID();
cy.get("@guid").then((uid) => {
dataSources.NavigateToDSCreateNew();
@ -39,17 +39,19 @@ describe("JSObjects OnLoad Actions tests", function() {
cy.get("@jsObjName").then((jsObjName) => {
jsName = jsObjName;
agHelper.EnterValue(
"SELECT * FROM public.users where id = {{" + jsObjName + ".getId.data}}",
);;
})
"SELECT * FROM public.users where id = {{" +
jsObjName +
".getId.data}}",
);
});
});
ee.SelectEntityByName("Table1", 'WIDGETS');
ee.SelectEntityByName("Table1", "WIDGETS");
jsEditor.EnterJSContext("Table Data", "{{GetUser.data}}");
agHelper.DeployApp();
agHelper.AssertElementPresence(jsEditor._dialog("Confirmation Dialog"));
agHelper.ClickButton("Yes");
agHelper.Sleep(1000)
agHelper.Sleep(1000);
agHelper.ValidateNetworkExecutionSuccess("@postExecute");
table.ReadTableRowColumnData(0, 0).then((cellData) => {

View File

@ -1,5 +1,5 @@
{
"runButton": ".run-button",
"runButton": ".run-js-action",
"editNameField": ".bp3-editable-text-input",
"outputConsole": ".CodeEditorTarget",
"jsObjectName": ".t--action-name-edit-field",

View File

@ -2,4 +2,6 @@ export default {
Modal: ".reconnect-datasource-modal",
ClostBtn: ".t--reconnect-close-btn",
SkipToAppBtn: ".t--skip-to-application-btn",
ImportSuccessModal: ".t--import-app-success-modal",
ImportSuccessModalCloseBtn: ".t--import-success-modal-got-it",
};

View File

@ -7,7 +7,6 @@ require("cypress-file-upload");
const googleForm = require("../locators/GoogleForm.json");
const googleData = require("../fixtures/googleSource.json");
const githubForm = require("../locators/GithubForm.json");
const githubData = require("../fixtures/githubSource.json");
Cypress.Commands.add("fillGoogleFormPartly", () => {
cy.get(googleForm.googleClientId).type(

View File

@ -20,6 +20,8 @@ export class DataSources {
private _datasourceCard = ".t--datasource"
_templateMenu = ".t--template-menu"
private _createQuery = ".t--create-query"
private _importSuccessModal = ".t--import-app-success-modal"
private _importSuccessModalClose = ".t--import-success-modal-got-it"
_visibleTextSpan = (spanText: string) => "//span[contains(text(),'" + spanText + "')]"
_dropdownTitle = (ddTitle: string) => "//p[contains(text(),'" + ddTitle + "')]/parent::label/following-sibling::div/div/div"
_reconnectModal = "div.reconnect-datasource-modal"
@ -112,6 +114,8 @@ export class DataSources {
this.ValidateNSelectDropdown("Connection Mode", "", "Read / Write")
this.FillPostgresDSForm()
cy.get(this._saveDs).click();
cy.get(this._importSuccessModal).should("be.visible");
cy.get(this._importSuccessModalClose).click({ force: true });
}
}

View File

@ -5,18 +5,39 @@ export class JSEditor {
public locator = ObjectsRegistry.CommonLocators;
public ee = ObjectsRegistry.EntityExplorer;
private _runButton = "//li//*[local-name() = 'svg' and @class='run-button']";
private _runButton = "button.run-js-action";
private _settingsTab = ".tab-title:contains('Settings')";
private _codeTab = ".tab-title:contains('Code')";
private _onPageLoadRadioButton = (functionName: string, onLoad: boolean) =>
`.${functionName}-on-page-load-setting label:contains(${
onLoad ? "Yes" : "No"
}) span.checkbox`;
private _confirmBeforeExecuteRadioButton = (
functionName: string,
shouldConfirm: boolean,
) =>
`.${functionName}-confirm-before-execute label:contains(${
shouldConfirm ? "Yes" : "No"
}) span.checkbox`;
private _outputConsole = ".CodeEditorTarget";
private _jsObjName = ".t--js-action-name-edit-field span";
private _jsObjTxt = ".t--js-action-name-edit-field input";
private _newJSobj = "span:contains('New JS Object')"
private _bindingsClose = ".t--entity-property-close"
private _propertyList = ".t--entity-property"
private _responseTabAction = (funName: string) => "//div[@class='function-name'][text()='" + funName + "']/following-sibling::div//*[local-name()='svg']"
private _functionSetting = (settingTxt: string) => "//span[text()='" + settingTxt + "']/parent::div/following-sibling::input[@type='checkbox']"
_dialog = (dialogHeader: string) => "//div[contains(@class, 'bp3-dialog')]//h4[contains(text(), '" + dialogHeader + "')]"
private _closeSettings = "span[icon='small-cross']"
private _newJSobj = "span:contains('New JS Object')";
private _bindingsClose = ".t--entity-property-close";
private _propertyList = ".t--entity-property";
private _responseTabAction = (funName: string) =>
"//div[@class='function-name'][text()='" +
funName +
"']/following-sibling::div//*[local-name()='svg']";
private _functionSetting = (settingTxt: string) =>
"//span[text()='" +
settingTxt +
"']/parent::div/following-sibling::input[@type='checkbox']";
_dialog = (dialogHeader: string) =>
"//div[contains(@class, 'bp3-dialog')]//h4[contains(text(), '" +
dialogHeader +
"')]";
private _closeSettings = "span[icon='small-cross']";
public NavigateToJSEditor() {
cy.get(this.locator._createNew)
@ -87,7 +108,7 @@ export class JSEditor {
if (toRun) {
//clicking 1 times & waits for 3 second for result to be populated!
Cypress._.times(1, () => {
cy.xpath(this._runButton)
cy.get(this._runButton)
.first()
.click()
.wait(3000);
@ -109,13 +130,21 @@ export class JSEditor {
this.agHelper.AssertAutoSave(); //Ample wait due to open bug # 10284
}
public EnterJSContext(endp: string, value: string, paste = true, toToggleOnJS = false, notField = false) {
public EnterJSContext(
endp: string,
value: string,
paste = true,
toToggleOnJS = false,
notField = false,
) {
if (toToggleOnJS) {
cy.get(this.locator._jsToggle(endp.replace(/ +/g, "").toLowerCase()))
.invoke("attr", "class")
.then((classes: any) => {
if (!classes.includes("is-active")) {
cy.get(this.locator._jsToggle(endp.replace(/ +/g, "").toLowerCase()))
cy.get(
this.locator._jsToggle(endp.replace(/ +/g, "").toLowerCase()),
)
.first()
.click({ force: true });
}
@ -131,20 +160,23 @@ export class JSEditor {
// .type("{del}", { force: true });
if (paste) {
this.agHelper.EnterValue(value, endp, notField)
}
else {
cy.get(this.locator._propertyControl + endp.replace(/ +/g, "").toLowerCase() + " " + this.locator._codeMirrorTextArea)
this.agHelper.EnterValue(value, endp, notField);
} else {
cy.get(
this.locator._propertyControl +
endp.replace(/ +/g, "").toLowerCase() +
" " +
this.locator._codeMirrorTextArea,
)
.first()
.then((el: any) => {
const input = cy.get(el);
input.type(value, {
parseSpecialCharSequences: false,
});
})
});
}
// cy.focused().then(($cm: any) => {
// if ($cm.contents != "") {
// cy.log("The field is not empty");
@ -175,18 +207,22 @@ export class JSEditor {
// });
// });
this.agHelper.AssertAutoSave()//Allowing time for Evaluate value to capture value
this.agHelper.AssertAutoSave(); //Allowing time for Evaluate value to capture value
}
public RemoveText(endp: string) {
cy.get(this.locator._propertyControl + endp + " " + this.locator._codeMirrorTextArea)
cy.get(
this.locator._propertyControl +
endp +
" " +
this.locator._codeMirrorTextArea,
)
.first()
.focus()
.type("{uparrow}", { force: true })
.type("{ctrl}{shift}{downarrow}", { force: true })
.type("{del}", { force: true });
this.agHelper.AssertAutoSave()
this.agHelper.AssertAutoSave();
}
public RenameJSObjFromForm(renameVal: string) {
@ -216,7 +252,7 @@ export class JSEditor {
public validateDefaultJSObjProperties(jsObjName: string) {
this.ee.ActionContextMenuByEntityName(jsObjName, "Show Bindings");
cy.get(this._propertyList).then(function ($lis) {
cy.get(this._propertyList).then(function($lis) {
const bindingsLength = $lis.length;
expect(bindingsLength).to.be.at.least(4);
expect($lis.eq(0).text()).to.be.oneOf([
@ -239,17 +275,22 @@ export class JSEditor {
cy.get(this._bindingsClose).click({ force: true });
}
public EnableOnPageLoad(funName: string, onLoad = true, bfrCalling = true) {
this.agHelper.GetNClick(this._responseTabAction(funName))
this.agHelper.AssertElementPresence(this._dialog('Function settings'))
if (onLoad)
this.agHelper.CheckUncheck(this._functionSetting(Cypress.env("MESSAGES").JS_SETTINGS_ONPAGELOAD()), true)
if (bfrCalling)
this.agHelper.CheckUncheck(this._functionSetting(Cypress.env("MESSAGES").JS_SETTINGS_CONFIRM_EXECUTION()), true)
this.agHelper.GetNClick(this._closeSettings)
public AddJSFunctionSettings(
funName: string,
onLoad = true,
bfrCalling = true,
) {
// Navigate to Settings tab
this.agHelper.GetNClick(this._settingsTab);
// Set onPageLoad
cy.get(this._onPageLoadRadioButton(funName, onLoad))
.first()
.click();
// Set confirmBeforeExecute
cy.get(this._confirmBeforeExecuteRadioButton(funName, bfrCalling))
.first()
.click();
// Return to code tab
this.agHelper.GetNClick(this._codeTab);
}
}

View File

@ -4,6 +4,7 @@ import {
} from "@appsmith/constants/ReduxActionConstants";
import { JSCollection, JSAction } from "entities/JSCollection";
import { RefactorAction, SetFunctionPropertyPayload } from "api/JSActionAPI";
export const createNewJSCollection = (
pageId: string,
): ReduxAction<{ pageId: string }> => ({
@ -89,3 +90,13 @@ export const updateJSFunction = (payload: SetFunctionPropertyPayload) => {
payload,
};
};
export const setActiveJSAction = (payload: {
jsCollectionId: string;
jsActionId: string;
}) => {
return {
type: ReduxActionTypes.SET_ACTIVE_JS_ACTION,
payload,
};
};

View File

@ -699,6 +699,7 @@ export const ReduxActionTypes = {
GET_TEMPLATE_SUCCESS: "GET_TEMPLATES_SUCCESS",
START_EXECUTE_JS_FUNCTION: "START_EXECUTE_JS_FUNCTION",
RESET_PAGE_LIST: "RESET_PAGE_LIST",
SET_ACTIVE_JS_ACTION: "SET_ACTIVE_JS_ACTION",
};
export type ReduxActionType = typeof ReduxActionTypes[keyof typeof ReduxActionTypes];

View File

@ -392,6 +392,8 @@ export const ACTION_CONFIGURATION_UPDATED = () => "Configuration updated";
export const WIDGET_PROPERTIES_UPDATED = () => "Widget properties were updated";
export const EMPTY_RESPONSE_FIRST_HALF = () => "🙌 Click on";
export const EMPTY_RESPONSE_LAST_HALF = () => "to get a response";
export const EMPTY_JS_RESPONSE_LAST_HALF = () =>
"to view response of selected function";
export const INVALID_EMAIL = () => "Please enter a valid email";
export const DEBUGGER_INTERCOM_TEXT = (text: string) =>
`Hi, \nI'm facing the following error on Appsmith, can you please help? \n\n${text}`;
@ -451,6 +453,8 @@ export const JS_EXECUTION_FAILURE = () => "JS Function execution failed";
export const JS_EXECUTION_FAILURE_TOASTER = () =>
"There was an error while executing function";
export const JS_SETTINGS_ONPAGELOAD = () => "Run function on page load (Beta)";
export const JS_EXECUTION_SUCCESS_TOASTER = (actionName: string) =>
`${actionName} ran successfully`;
export const JS_SETTINGS_ONPAGELOAD_SUBTEXT = () =>
"Will refresh data every time page is reloaded";
export const JS_SETTINGS_CONFIRM_EXECUTION = () =>
@ -459,6 +463,13 @@ export const JS_SETTINGS_CONFIRM_EXECUTION_SUBTEXT = () =>
"Ask confirmation from the user every time before refreshing data";
export const JS_SETTINGS_EXECUTE_TIMEOUT = () =>
"Function Timeout (in milliseconds)";
export const ASYNC_FUNCTION_SETTINGS_HEADING = () => "Async Function Settings";
export const NO_ASYNC_FUNCTIONS = () =>
"There is no asynchronous function in this JSObject";
export const NO_JS_FUNCTION_TO_RUN = (JSObjectName: string) =>
`${JSObjectName} has no function`;
export const NO_JS_FUNCTION_RETURN_VALUE = (JSFunctionName: string) =>
`${JSFunctionName} did not return any data. Did you add a return statement?`;
// Import/Export Application features
export const IMPORT_APPLICATION_MODAL_TITLE = () => "Import application";
@ -688,6 +699,10 @@ export const CONTACT_SALES_MESSAGE_ON_INTERCOM = (orgName: string) =>
export const REPOSITORY_LIMIT_REACHED = () => "Repository Limit Reached";
export const REPOSITORY_LIMIT_REACHED_INFO = () =>
"Adding and using upto 3 repositories is free. To add more repositories kindly upgrade.";
export const APPLICATION_IMPORT_SUCCESS = (username: string) =>
`${username}! Your application is ready to use.`;
export const APPLICATION_IMPORT_SUCCESS_DESCRIPTION = () =>
"All your datasources are configuered and ready to use.";
export const NONE_REVERSIBLE_MESSAGE = () =>
"This action is non reversible. Proceed with caution.";
export const CONTACT_SUPPORT_TO_UPGRADE = () =>
@ -999,6 +1014,9 @@ export const TEST_EMAIL_SUCCESS = (email: string) => () =>
`Test email sent, please check the inbox of ${email}`;
export const TEST_EMAIL_SUCCESS_TROUBLESHOOT = () => "Troubleshoot";
export const TEST_EMAIL_FAILURE = () => "Sending Test Email Failed";
export const DISCONNECT_AUTH_ERROR = () =>
"Cannot disconnect the only connected authentication method.";
export const MANDATORY_FIELDS_ERROR = () => "Mandatory fields cannot be empty";
//Reflow Beta Screen
export const REFLOW_BETA_CHECKBOX_LABEL = () =>
"Turn on new drag & drop experience";

View File

@ -23,6 +23,7 @@ import Icon from "components/ads/Icon";
import TooltipComponent from "components/ads/Tooltip";
import { Position } from "@blueprintjs/core";
import { adminSettingsCategoryUrl } from "RouteBuilder";
import AnalyticsUtil from "utils/AnalyticsUtil";
const { intercomAppID } = getAppsmithConfigs();
@ -149,6 +150,30 @@ export function AuthPage({ authMethods }: { authMethods: AuthMethodType[] }) {
}
};
const onClickHandler = (method: AuthMethodType) => {
if (!method.needsUpgrade || method.isConnected) {
AnalyticsUtil.logEvent(
method.isConnected
? "ADMIN_SETTINGS_EDIT_AUTH_METHOD"
: "ADMIN_SETTINGS_ENABLE_AUTH_METHOD",
{
method: method.label,
},
);
history.push(
adminSettingsCategoryUrl({
category: SettingCategories.AUTHENTICATION,
subCategory: method.category,
}),
);
} else {
AnalyticsUtil.logEvent("ADMIN_SETTINGS_UPGRADE_AUTH_METHOD", {
method: method.label,
});
triggerIntercom(method.label);
}
};
return (
<Wrapper>
<SettingsFormWrapper>
@ -211,16 +236,7 @@ export function AuthPage({ authMethods }: { authMethods: AuthMethodType[] }) {
: method.category
}`}
data-cy="btn-auth-account"
onClick={() =>
!method.needsUpgrade || method.isConnected
? history.push(
adminSettingsCategoryUrl({
category: SettingCategories.AUTHENTICATION,
subCategory: method.category,
}),
)
: triggerIntercom(method.label)
}
onClick={() => onClickHandler(method)}
text={createMessage(
method.isConnected
? EDIT

View File

@ -13,6 +13,7 @@ export enum SettingTypes {
UNEDITABLEFIELD = "UNEDITABLEFIELD",
ACCORDION = "ACCORDION",
TAGINPUT = "TAGINPUT",
DROPDOWN = "DROPDOWN",
}
export enum SettingSubtype {
@ -52,6 +53,7 @@ export interface Setting {
isRequired?: boolean;
formName?: string;
fieldName?: string;
dropdownOptions?: Array<{ id: string; value: string; label?: string }>;
}
export interface Category {

View File

@ -424,7 +424,7 @@ const ButtonStyles = css<ThemeProp & ButtonProps>`
}
`;
const StyledButton = styled("button")`
export const StyledButton = styled("button")`
${ButtonStyles}
`;

View File

@ -19,7 +19,11 @@ const Wrapper = styled.div<{ offset?: string }>`
}
`;
function CopyToClipboard(props: { copyText: string; btnWidth?: string }) {
function CopyToClipboard(props: {
className?: string;
copyText: string;
btnWidth?: string;
}) {
const { copyText } = props;
const copyURLInput = createRef<HTMLInputElement>();
const [isCopied, setIsCopied] = useState(false);
@ -38,7 +42,7 @@ function CopyToClipboard(props: { copyText: string; btnWidth?: string }) {
}
};
return (
<Wrapper offset={props.btnWidth}>
<Wrapper className={props.className} offset={props.btnWidth}>
<TextInput
defaultValue={copyText}
onChange={() => {

View File

@ -26,8 +26,9 @@ const StyledDialog = styled(Dialog)<{
padding: 0;
background: ${(props) => props.theme.colors.modal.bg};
box-shadow: none;
.${Classes.ICON} {
color: ${(props) => props.theme.colors.modal.iconColor};
min-height: unset;
svg {
color: ${Colors.GREY_800};
}
.${Classes.BUTTON}.${Classes.MINIMAL}:hover {
@ -39,19 +40,23 @@ const StyledDialog = styled(Dialog)<{
color: ${(props) => props.theme.colors.modal.headerText};
font-weight: ${(props) => props.theme.typography.h1.fontWeight};
font-size: ${(props) => props.theme.typography.h1.fontSize}px;
line-height: ${(props) => props.theme.typography.h1.lineHeight}px;
line-height: unset;
letter-spacing: ${(props) => props.theme.typography.h1.letterSpacing};
}
.${Classes.DIALOG_CLOSE_BUTTON} {
color: ${Colors.CHARCOAL};
color: ${Colors.SCORPION};
min-width: 0;
padding: 0;
svg {
fill: ${Colors.CHARCOAL};
fill: ${Colors.SCORPION};
width: 24px;
height: 24px;
&:hover {
fill: ${Colors.COD_GRAY};
}
}
}
@ -78,7 +83,7 @@ const StyledDialog = styled(Dialog)<{
& .${Classes.DIALOG_BODY} {
margin: 0;
margin-top: ${(props) => (props.noModalBodyMarginTop ? "0px" : "24px")};
margin-top: ${(props) => (props.noModalBodyMarginTop ? "0px" : "16px")};
overflow: auto;
}

View File

@ -61,6 +61,10 @@ export function DraggableListCard(props: RenderComponentProps) {
const ref = useRef<HTMLInputElement | null>(null);
const debouncedUpdate = _.debounce(updateOption, 1000);
useEffect(() => {
setVisibility(item.isVisible);
}, [item.isVisible]);
useEffect(() => {
if (!isEditing && item && item.label) setValue(item.label);
}, [item?.label, isEditing]);

View File

@ -11,7 +11,7 @@ import Dropdown from "./Dropdown";
import { lightTheme } from "selectors/themeSelectors";
import userEvent from "@testing-library/user-event";
const props = {
const optionsProps: any = {
options: [
{ label: "Primary", value: "PRIMARY" },
{ label: "Secondary", value: "SECONDARY" },
@ -24,8 +24,13 @@ const props = {
showLabelOnly: true,
};
const noOptionsProps = {
options: [],
};
const getTestComponent = (
handleOnSelect: any = undefined,
props = optionsProps,
allowDeselection?: boolean,
) => (
<ThemeProvider theme={lightTheme}>
@ -135,15 +140,15 @@ describe("<Dropdown /> - Keyboard Navigation", () => {
userEvent.keyboard("{ArrowDown}");
userEvent.keyboard("{Enter}");
expect(handleOnSelect).toHaveBeenLastCalledWith(
props.options[1].value,
props.options[1],
optionsProps.options[1].value,
optionsProps.options[1],
);
userEvent.keyboard("{Enter}");
userEvent.keyboard("{ArrowDown}");
userEvent.keyboard(" ");
expect(handleOnSelect).toHaveBeenLastCalledWith(
props.options[2].value,
props.options[2],
optionsProps.options[2].value,
optionsProps.options[2],
);
});
@ -179,26 +184,26 @@ describe("<Dropdown /> - allowDeselection behaviour", () => {
// click on Second Item
fireEvent.click(screen.queryAllByRole("option")[1]);
expect(screen.getByRole("option", { selected: true })).toHaveTextContent(
props.options[1].label,
optionsProps.options[1].label,
);
expect(screen.getByRole("listbox")).toHaveTextContent(
props.options[1].label,
optionsProps.options[1].label,
);
// click on Second Item Again
fireEvent.click(screen.queryAllByRole("option")[1]);
expect(screen.getByRole("option", { selected: true })).toHaveTextContent(
props.options[1].label,
optionsProps.options[1].label,
);
expect(screen.getByRole("listbox")).toHaveTextContent(
props.options[1].label,
optionsProps.options[1].label,
);
expect(screen.getByRole("option", { selected: true })).not.toBeNull();
});
it("Test allowDeselection = true behaviour", async () => {
const handleOnSelect = jest.fn();
render(getTestComponent(handleOnSelect, true));
render(getTestComponent(handleOnSelect, optionsProps, true));
expect(screen.getByRole("listbox")).toHaveTextContent("Primary");
const dropdown = screen
@ -213,10 +218,10 @@ describe("<Dropdown /> - allowDeselection behaviour", () => {
// click on Third Item
fireEvent.click(screen.queryAllByRole("option")[2]);
expect(screen.getByRole("option", { selected: true })).toHaveTextContent(
props.options[2].label,
optionsProps.options[2].label,
);
expect(screen.getByRole("listbox")).toHaveTextContent(
props.options[2].label,
optionsProps.options[2].label,
);
// click on Third Item Again, that should unselect everything
@ -224,3 +229,19 @@ describe("<Dropdown /> - allowDeselection behaviour", () => {
expect(screen.queryByRole("option", { selected: true })).toBeNull();
});
});
describe("<Dropdown /> - when the options is an empty array", () => {
it("Hide options renderer when option list is empty", () => {
const handleOnSelect = jest.fn();
render(getTestComponent(handleOnSelect, noOptionsProps));
const dropdown = screen
.getByRole("listbox")
.querySelector(".bp3-popover-target");
expect(dropdown).not.toBeNull();
// open dropdown
fireEvent.click(dropdown as Element);
expect(screen.queryByTestId("dropdown-options-wrapper")).toBeNull();
});
});

View File

@ -38,6 +38,7 @@ export type DropdownOption = {
onSelect?: DropdownOnSelect;
data?: any;
isSectionHeader?: boolean;
hasCustomBadge?: boolean;
};
export interface DropdownSearchProps {
enableSearch?: boolean;
@ -101,6 +102,8 @@ export type DropdownProps = CommonComponentProps &
defaultIcon?: IconName;
allowDeselection?: boolean; //prevents de-selection of the selected option
truncateOption?: boolean; // enabled wrapping and adding tooltip on option item of dropdown menu
customBadge?: JSX.Element;
selectedHighlightBg?: string;
};
export interface DefaultDropDownValueNodeProps {
selected: DropdownOption | DropdownOption[];
@ -259,6 +262,10 @@ export const DropdownContainer = styled.div<{ width: string; height?: string }>`
span.bp3-popover-target {
display: inline-block;
width: 100%;
height: 100%;
}
span.bp3-popover-target div {
height: 100%;
}
span.bp3-popover-wrapper {
@ -333,6 +340,7 @@ const DropdownOptionsWrapper = styled.div<{
const OptionWrapper = styled.div<{
selected: boolean;
selectedHighlightBg?: string;
}>`
padding: ${(props) => props.theme.spaces[2] + 1}px
${(props) => props.theme.spaces[5]}px;
@ -340,7 +348,9 @@ const OptionWrapper = styled.div<{
display: flex;
align-items: center;
min-height: 36px;
background-color: ${(props) => (props.selected ? Colors.GREEN_3 : null)};
background-color: ${(props) =>
props.selected ? props.selectedHighlightBg || Colors.GREEN_3 : null};
&&& svg {
rect {
fill: ${(props) => props.theme.colors.dropdownIconBg};
@ -371,7 +381,7 @@ const OptionWrapper = styled.div<{
}
&:hover {
background-color: ${Colors.GREEN_3};
background-color: ${(props) => props.selectedHighlightBg || Colors.GREEN_3};
&&& svg {
rect {
@ -668,9 +678,12 @@ export function RenderDropdownOptions(props: DropdownOptionsProps) {
};
const theme = useTheme() as Theme;
if (!options.length) return null;
return (
<DropdownWrapper
className="ads-dropdown-options-wrapper"
data-testid="dropdown-options-wrapper"
isOpen={props.isOpen}
width={optionWidth}
>
@ -724,6 +737,7 @@ export function RenderDropdownOptions(props: DropdownOptionsProps) {
}
role="option"
selected={isSelected}
selectedHighlightBg={props.selectedHighlightBg}
>
{option.leftElement && (
<LeftIconWrapper>{option.leftElement}</LeftIconWrapper>
@ -747,12 +761,18 @@ export function RenderDropdownOptions(props: DropdownOptionsProps) {
) : null}
{props.showLabelOnly ? (
props.truncateOption ? (
<TooltipWrappedText
label={option.label || ""}
type={TextType.P1}
/>
<>
<TooltipWrappedText
label={option.label || ""}
type={TextType.P1}
/>
{option.hasCustomBadge && props.customBadge}
</>
) : (
<Text type={TextType.P1}>{option.label}</Text>
<>
<Text type={TextType.P1}>{option.label}</Text>
{option.hasCustomBadge && props.customBadge}
</>
)
) : option.label && option.value ? (
<LabelWrapper className="label-container">

View File

@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react";
import styled from "styled-components";
import * as log from "loglevel";
type OptionProps = {
export type OptionProps = {
label: string;
value: string;
disabled?: boolean;
@ -17,6 +17,9 @@ export type RadioProps = CommonComponentProps & {
onSelect?: (value: string) => void;
options: OptionProps[];
backgroundColor?: string;
// To prevent interference when there are multiple radio groups,
// options corresponding to the same radio should have same name, which is different from others.
name?: string;
};
const RadioGroup = styled.div<{
@ -149,7 +152,7 @@ export default function RadioComponent(props: RadioProps) {
<input
checked={selected === option.value}
disabled={props.disabled || option.disabled}
name="radio"
name={props.name || "radio"}
onChange={(e) => option.onSelect && option.onSelect(e.target.value)}
type="radio"
value={option.value}

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { RefObject, useCallback, useState } from "react";
import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
import "react-tabs/style/react-tabs.css";
import styled from "styled-components";
@ -6,6 +6,10 @@ import Icon, { IconName, IconSize } from "./Icon";
import { Classes, CommonComponentProps } from "./common";
import { useEffect } from "react";
import { Indices } from "constants/Layers";
import { theme } from "constants/DefaultTheme";
import useResizeObserver from "utils/hooks/useResizeObserver";
export const TAB_MIN_HEIGHT = `36px`;
export type TabProp = {
key: string;
@ -28,7 +32,7 @@ const TabsWrapper = styled.div<{
height: 100%;
}
.react-tabs__tab-panel {
height: calc(100% - 36px);
height: ${() => `calc(100% - ${TAB_MIN_HEIGHT})`};
overflow: auto;
}
.react-tabs__tab-list {
@ -251,6 +255,13 @@ const TabTitleWrapper = styled.div<{
: ""}
`;
const CollapseIconWrapper = styled.div`
position: absolute;
right: 14px;
top: ${() => theme.spaces[3] - 1}px;
cursor: pointer;
`;
export type TabItemProps = {
tab: TabProp;
selected: boolean;
@ -288,18 +299,88 @@ export type TabbedViewComponentType = CommonComponentProps & {
vertical?: boolean;
tabItemComponent?: (props: TabItemProps) => JSX.Element;
responseViewer?: boolean;
canCollapse?: boolean;
// Reference to container for collapsing or expanding content
containerRef?: RefObject<HTMLElement>;
// height of container when expanded
expandedHeight?: string;
};
export function TabComponent(props: TabbedViewComponentType) {
// Props required to support a collapsible (foldable) tab component
export type CollapsibleTabProps = {
// Reference to container for collapsing or expanding content
containerRef: RefObject<HTMLDivElement>;
// height of container when expanded( usually the default height of the tab component)
expandedHeight: string;
};
export type CollapsibleTabbedViewComponentType = TabbedViewComponentType &
CollapsibleTabProps;
export const collapsibleTabRequiredPropKeys: Array<keyof CollapsibleTabProps> = [
"containerRef",
"expandedHeight",
];
// Tab is considered collapsible only when all required collapsible props are present
export const isCollapsibleTabComponent = (
props: TabbedViewComponentType | CollapsibleTabbedViewComponentType,
): props is CollapsibleTabbedViewComponentType =>
collapsibleTabRequiredPropKeys.every((key) => key in props);
export function TabComponent(
props: TabbedViewComponentType | CollapsibleTabbedViewComponentType,
) {
const TabItem = props.tabItemComponent || DefaultTabItem;
// for setting selected state of an uncontrolled component
const [selectedIndex, setSelectedIndex] = useState(props.selectedIndex || 0);
const [isExpanded, setIsExpanded] = useState(true);
useEffect(() => {
if (typeof props.selectedIndex === "number")
setSelectedIndex(props.selectedIndex);
}, [props.selectedIndex]);
const handleContainerResize = () => {
if (!isCollapsibleTabComponent(props)) return;
const { containerRef, expandedHeight } = props;
if (containerRef?.current && expandedHeight) {
containerRef.current.style.height = isExpanded
? TAB_MIN_HEIGHT
: expandedHeight;
}
setIsExpanded((prev) => !prev);
};
const resizeCallback = useCallback(
(entries: ResizeObserverEntry[]) => {
if (entries && entries.length) {
const {
contentRect: { height },
} = entries[0];
if (height > Number(TAB_MIN_HEIGHT.replace("px", "")) + 6) {
!isExpanded && setIsExpanded(true);
} else {
isExpanded && setIsExpanded(false);
}
}
},
[isExpanded],
);
useResizeObserver(
isCollapsibleTabComponent(props) ? props.containerRef?.current : null,
resizeCallback,
);
useEffect(() => {
if (!isCollapsibleTabComponent(props)) return;
const { containerRef } = props;
if (!isExpanded && containerRef.current) {
containerRef.current.style.height = TAB_MIN_HEIGHT;
}
}, [isExpanded]);
return (
<TabsWrapper
data-cy={props.cypressSelector}
@ -307,6 +388,16 @@ export function TabComponent(props: TabbedViewComponentType) {
shouldOverflow={props.overflow}
vertical={props.vertical}
>
{isCollapsibleTabComponent(props) && (
<CollapseIconWrapper>
<Icon
name={isExpanded ? "expand-more" : "expand-less"}
onClick={handleContainerResize}
size={IconSize.XXXXL}
/>
</CollapseIconWrapper>
)}
<Tabs
onSelect={(index: number) => {
props.onSelect && props.onSelect(index);

View File

@ -0,0 +1,77 @@
import React, { useEffect, useState } from "react";
import {
Field,
WrappedFieldMetaProps,
WrappedFieldInputProps,
} from "redux-form";
import Dropdown from "components/ads/Dropdown";
type DropdownWrapperProps = {
placeholder: string;
input?: {
value?: string;
onChange?: (value?: string) => void;
};
options: Array<{ id: string; value: string; label?: string }>;
fillOptions?: boolean;
};
function DropdownWrapper(props: DropdownWrapperProps) {
const [selectedOption, setSelectedOption] = useState({
value: props.placeholder,
});
const onSelectHandler = (value?: string) => {
props.input && props.input.onChange && props.input.onChange(value);
};
useEffect(() => {
if (props.input && props.input.value) {
setSelectedOption({ value: props.input.value });
} else if (props.placeholder) {
setSelectedOption({ value: props.placeholder });
}
}, [props.input, props.placeholder]);
return (
<Dropdown
fillOptions={props.fillOptions}
onSelect={onSelectHandler}
options={props.options}
selected={selectedOption}
/>
);
}
const renderComponent = (
componentProps: SelectFieldProps & {
meta: Partial<WrappedFieldMetaProps>;
input: Partial<WrappedFieldInputProps>;
},
) => {
return <DropdownWrapper {...componentProps} />;
};
type SelectFieldProps = {
name: string;
placeholder: string;
options: Array<{ id: string; value: string; label?: string }>;
size?: "large" | "small";
outline?: boolean;
fillOptions?: boolean;
};
export function SelectField(props: SelectFieldProps) {
return (
<Field
component={renderComponent}
fillOptions={props.fillOptions}
name={props.name}
options={props.options}
outline={props.outline}
placeholder={props.placeholder}
size={props.size}
/>
);
}
export default SelectField;

View File

@ -191,7 +191,7 @@ function getFieldFromValue(
const errorArg = args[1] ? args[1][0] : "() => {}";
const successArg = changeValue.endsWith(")")
? `() => ${changeValue}`
: `() => ${changeValue}()`;
: `() => {}`;
return value.replace(
ACTION_TRIGGER_REGEX,
@ -217,7 +217,8 @@ function getFieldFromValue(
const successArg = args[0] ? args[0][0] : "() => {}";
const errorArg = changeValue.endsWith(")")
? `() => ${changeValue}`
: `() => ${changeValue}()`;
: `() => {}`;
return value.replace(
ACTION_TRIGGER_REGEX,
`{{$1(${successArg}, ${errorArg})}}`,

View File

@ -153,9 +153,9 @@ const StyledCallout = styled(Callout)`
}
`;
const InlineButton = styled(Button)`
export const InlineButton = styled(Button)`
display: inline-flex;
margin: 0 4px;
margin: 0 8px;
`;
const HelpSection = styled.div`

View File

@ -7,8 +7,7 @@ export const WARNING_LINT_ERRORS = {
export const LINT_TOOLTIP_CLASS = "CodeMirror-lint-tooltip";
export const LINT_TOOLTIP_JUSTIFIFIED_LEFT_CLASS =
"CodeMirror-lint-tooltip-left";
export const LINT_TOOLTIP_JUSTIFIED_LEFT_CLASS = "CodeMirror-lint-tooltip-left";
export enum LintTooltipDirection {
left = "left",

View File

@ -91,7 +91,7 @@ import { replayHighlightClass } from "globalStyles/portals";
import {
LintTooltipDirection,
LINT_TOOLTIP_CLASS,
LINT_TOOLTIP_JUSTIFIFIED_LEFT_CLASS,
LINT_TOOLTIP_JUSTIFIED_LEFT_CLASS,
} from "./constants";
interface ReduxStateProps {
@ -135,6 +135,31 @@ export type EditorStyleProps = {
popperPlacement?: Placement;
popperZIndex?: Indices;
};
/**
* line => Line to which the gutter is added
*
* element => HTML Element that gets added to line
*
* isFocusedAction => function called when focused
*/
export type GutterConfig = {
line: number;
element: HTMLElement;
isFocusedAction: () => void;
};
export type CodeEditorGutter = {
getGutterConfig:
| ((editorValue: string, cursorLineNumber: number) => GutterConfig | null)
| null;
gutterId: string;
};
export type CustomKeyMap = {
// combination of keys
combination: string;
onKeyDown: (cm: CodeMirror.Editor) => void;
};
export type EditorProps = EditorStyleProps &
EditorConfig & {
@ -153,6 +178,8 @@ export type EditorProps = EditorStyleProps &
handleMouseLeave?: () => void;
isReadOnly?: boolean;
isRawView?: boolean;
// Custom gutter
customGutter?: CodeEditorGutter;
};
type Props = ReduxStateProps &
@ -198,6 +225,7 @@ class CodeEditor extends Component<Props, State> {
componentDidMount(): void {
if (this.codeEditorTarget.current) {
const options: EditorConfiguration = {
autoRefresh: true,
mode: this.props.mode,
theme: EditorThemes[this.props.theme],
viewportMargin: 10,
@ -221,6 +249,8 @@ class CodeEditor extends Component<Props, State> {
tabindex: -1,
};
const gutters = new Set<string>();
if (!this.props.input.onChange || this.props.disabled) {
options.readOnly = true;
options.scrollbarStyle = "null";
@ -230,9 +260,13 @@ class CodeEditor extends Component<Props, State> {
if (this.props.tabBehaviour === TabBehaviour.INPUT) {
options.extraKeys["Tab"] = false;
}
if (this.props.customGutter) {
gutters.add(this.props.customGutter.gutterId);
}
if (this.props.folding) {
options.foldGutter = true;
options.gutters = ["CodeMirror-linenumbers", "CodeMirror-foldgutter"];
gutters.add("CodeMirror-linenumbers");
gutters.add("CodeMirror-foldgutter");
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
options.foldOptions = {
@ -241,6 +275,7 @@ class CodeEditor extends Component<Props, State> {
},
};
}
options.gutters = Array.from(gutters);
// Set value of the editor
const inputValue = getInputValue(this.props.input.value) || "";
@ -262,7 +297,6 @@ class CodeEditor extends Component<Props, State> {
// which means CodeMirror recalculates itself only one time, once all CodeMirror
// changes here are completed
//
editor.on("beforeChange", this.handleBeforeChange);
editor.on("change", this.startChange);
editor.on("keyup", this.handleAutocompleteKeyup);
@ -270,6 +304,7 @@ class CodeEditor extends Component<Props, State> {
editor.on("cursorActivity", this.handleCursorMovement);
editor.on("blur", this.handleEditorBlur);
editor.on("postPick", () => this.handleAutocompleteVisibility(editor));
if (this.props.height) {
editor.setSize("100%", this.props.height);
} else {
@ -330,7 +365,8 @@ class CodeEditor extends Component<Props, State> {
});
}
handleMouseMove = () => {
handleMouseMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
this.handleCustomGutter(this.editor.lineAtHeight(e.clientY, "window"));
// this code only runs when we want custom tool tip for any highlighted text inside codemirror instance
if (
this.props.showCustomToolTipForHighlightedText &&
@ -413,7 +449,29 @@ class CodeEditor extends Component<Props, State> {
});
}
handleCustomGutter = (lineNumber: number | null, isFocused = false) => {
const { customGutter } = this.props;
const editor = this.editor;
if (!customGutter || !editor) return;
editor.clearGutter(customGutter.gutterId);
if (lineNumber && customGutter.getGutterConfig) {
const gutterConfig = customGutter.getGutterConfig(
editor.getValue(),
lineNumber,
);
if (!gutterConfig) return;
editor.setGutterMarker(
gutterConfig.line,
customGutter.gutterId,
gutterConfig.element,
);
isFocused && gutterConfig.isFocusedAction();
}
};
handleCursorMovement = (cm: CodeMirror.Editor) => {
this.handleCustomGutter(cm.getCursor().line, true);
// ignore if disabled
if (!this.props.input.onChange || this.props.disabled) {
return;
@ -445,6 +503,7 @@ class CodeEditor extends Component<Props, State> {
this.handleChange();
this.setState({ isFocused: false });
this.editor.setOption("matchBrackets", false);
this.handleCustomGutter(null);
};
handleBeforeChange = (
@ -474,7 +533,7 @@ class CodeEditor extends Component<Props, State> {
tooltip &&
getLintTooltipDirection(tooltip) === LintTooltipDirection.left
) {
tooltip.classList.add(LINT_TOOLTIP_JUSTIFIFIED_LEFT_CLASS);
tooltip.classList.add(LINT_TOOLTIP_JUSTIFIED_LEFT_CLASS);
}
}
};

View File

@ -19,7 +19,7 @@ const ContainerWrapper = styled.div`
height: 100%;
`;
const ListWrapper = styled.div`
export const ListWrapper = styled.div`
overflow: auto;
height: calc(100% - ${LIST_HEADER_HEIGHT});
${thinScrollbar};

View File

@ -1,7 +1,12 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, RefObject } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setCurrentTab } from "actions/debuggerActions";
import { TabComponent, TabProp } from "components/ads/Tabs";
import {
CollapsibleTabProps,
collapsibleTabRequiredPropKeys,
TabComponent,
TabProp,
} from "components/ads/Tabs";
import { getCurrentDebuggerTab } from "selectors/debuggerSelectors";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { DEBUGGER_TAB_KEYS } from "./Debugger/helpers";
@ -12,9 +17,26 @@ type EntityBottomTabsProps = {
responseViewer?: boolean;
onSelect?: (tab: any) => void;
selectedTabIndex?: number; // this is used in the event you want to directly control the index changes.
canCollapse?: boolean;
// Reference to container for collapsing or expanding content
containerRef?: RefObject<HTMLElement>;
// height of container when expanded
expandedHeight?: string;
};
type CollapsibleEntityBottomTabsProps = EntityBottomTabsProps &
CollapsibleTabProps;
// Tab is considered collapsible only when all required collapsible props are present
export const isCollapsibleEntityBottomTab = (
props: EntityBottomTabsProps | CollapsibleEntityBottomTabsProps,
): props is CollapsibleEntityBottomTabsProps =>
collapsibleTabRequiredPropKeys.every((key) => key in props);
// Using this if there are debugger related tabs
function EntityBottomTabs(props: EntityBottomTabsProps) {
function EntityBottomTabs(
props: EntityBottomTabsProps | CollapsibleEntityBottomTabsProps,
) {
const [selectedIndex, setSelectedIndex] = useState(props.defaultIndex);
const currentTab = useSelector(getCurrentDebuggerTab);
const dispatch = useDispatch();
@ -51,6 +73,12 @@ function EntityBottomTabs(props: EntityBottomTabsProps) {
props.selectedTabIndex ? props.selectedTabIndex : selectedIndex
}
tabs={props.tabs}
{...(isCollapsibleEntityBottomTab(props)
? {
containerRef: props.containerRef,
expandedHeight: props.expandedHeight,
}
: {})}
/>
);
}

View File

@ -1,4 +1,10 @@
import React, { useState, useRef, RefObject, useCallback } from "react";
import React, {
useEffect,
useRef,
RefObject,
useCallback,
useState,
} from "react";
import { connect, useDispatch } from "react-redux";
import { withRouter, RouteComponentProps } from "react-router";
import styled from "styled-components";
@ -9,10 +15,10 @@ import {
DEBUGGER_ERRORS,
DEBUGGER_LOGS,
EXECUTING_FUNCTION,
EMPTY_JS_OBJECT,
PARSING_ERROR,
EMPTY_RESPONSE_FIRST_HALF,
EMPTY_RESPONSE_LAST_HALF,
EMPTY_JS_RESPONSE_LAST_HALF,
NO_JS_FUNCTION_RETURN_VALUE,
} from "@appsmith/constants/messages";
import { EditorTheme } from "./CodeEditor/EditorConfig";
import DebuggerLogs from "./Debugger/DebuggerLogs";
@ -21,40 +27,35 @@ import Resizer, { ResizerCSS } from "./Debugger/Resizer";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { JSCollection, JSAction } from "entities/JSCollection";
import ReadOnlyEditor from "components/editorComponents/ReadOnlyEditor";
import { startExecutingJSFunction } from "actions/jsPaneActions";
import Text, { TextType } from "components/ads/Text";
import { Classes } from "components/ads/common";
import LoadingOverlayScreen from "components/editorComponents/LoadingOverlayScreen";
import { sortBy } from "lodash";
import { ReactComponent as JSFunction } from "assets/icons/menu/js-function.svg";
import { ReactComponent as RunFunction } from "assets/icons/menu/run.svg";
import { JSCollectionData } from "reducers/entityReducers/jsActionsReducer";
import Callout from "components/ads/Callout";
import { Variant } from "components/ads/common";
import { EvaluationError } from "utils/DynamicBindingUtils";
import { Severity } from "entities/AppsmithConsole";
import { getJSCollectionIdFromURL } from "pages/Editor/Explorer/helpers";
import { DebugButton } from "./Debugger/DebugCTA";
import { thinScrollbar } from "constants/DefaultTheme";
import { setCurrentTab } from "actions/debuggerActions";
import { DEBUGGER_TAB_KEYS } from "./Debugger/helpers";
import EntityBottomTabs from "./EntityBottomTabs";
import Icon from "components/ads/Icon";
import { ReactComponent as FunctionSettings } from "assets/icons/menu/settings.svg";
import JSFunctionSettings from "pages/Editor/JSEditor/JSFunctionSettings";
import FlagBadge from "components/utils/FlagBadge";
import { TAB_MIN_HEIGHT } from "components/ads/Tabs";
import { theme } from "constants/DefaultTheme";
import { Button, Size } from "components/ads";
import { CodeEditorWithGutterStyles } from "pages/Editor/JSEditor/constants";
const ResponseContainer = styled.div`
${ResizerCSS}
// Initial height of bottom tabs
height: ${(props) => props.theme.actionsBottomTabInitialHeight};
width: 100%;
// Minimum height of bottom tabs as it can be resized
min-height: 36px;
min-height: ${TAB_MIN_HEIGHT};
background-color: ${(props) => props.theme.colors.apiPane.responseBody.bg};
height: ${({ theme }) => theme.actionsBottomTabInitialHeight};
.react-tabs__tab-panel {
overflow: hidden;
${CodeEditorWithGutterStyles}
overflow-y: auto;
height: calc(100% - ${TAB_MIN_HEIGHT});
}
`;
@ -71,79 +72,39 @@ const ResponseTabWrapper = styled.div`
}
`;
const ResponseTabActionsList = styled.ul`
height: 100%;
width: 20%;
list-style: none;
padding-left: 0;
${thinScrollbar};
scrollbar-width: thin;
overflow: auto;
padding-bottom: 40px;
margin-top: 0;
`;
const ResponseTabAction = styled.li`
padding: 10px 0px 10px 20px;
display: flex;
align-items: center;
&:hover {
cursor: pointer;
background-color: #f0f0f0;
}
.function-name {
margin-left: 5px;
display: inline-block;
flex: 1;
}
.function-actions {
margin-left: auto;
order: 2;
svg {
display: inline-block;
}
}
.run-button {
margin: 0 15px;
margin-left: 10px;
}
&.active {
background-color: #f0f0f0;
}
`;
const TabbedViewWrapper = styled.div`
height: 100%;
&&& {
ul.react-tabs__tab-list {
padding: 0px ${(props) => props.theme.spaces[12]}px;
height: ${TAB_MIN_HEIGHT};
}
}
`;
const ResponseViewer = styled.div`
width: 80%;
width: 100%;
`;
const NoResponseContainer = styled.div`
height: 100%;
width: 100%;
width: max-content;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin: 0 auto;
&.empty {
background-color: #fafafa;
}
.${Classes.ICON} {
margin-right: 0px;
svg {
width: 150px;
width: auto;
height: 150px;
}
}
.${Classes.TEXT} {
margin-top: ${(props) => props.theme.spaces[9]}px;
color: #090707;
@ -166,6 +127,23 @@ const StyledCallout = styled(Callout)`
}
`;
const NoReturnValueWrapper = styled.div`
padding-left: ${(props) => props.theme.spaces[12]}px;
padding-top: ${(props) => props.theme.spaces[6]}px;
`;
const InlineButton = styled(Button)`
display: inline-flex;
margin: 0 4px;
`;
enum JSResponseState {
IsExecuting = "IsExecuting",
IsDirty = "IsDirty",
NoResponse = "NoResponse",
ShowResponse = "ShowResponse",
NoReturnValue = "NoReturnValue",
}
interface ReduxStateProps {
responses: Record<string, any>;
isExecuting: Record<string, boolean>;
@ -173,26 +151,35 @@ interface ReduxStateProps {
type Props = ReduxStateProps &
RouteComponentProps<JSEditorRouteParams> & {
currentFunction: JSAction | null;
theme?: EditorTheme;
jsObject: JSCollection;
errors: Array<EvaluationError>;
disabled: boolean;
isLoading: boolean;
onButtonClick: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
};
function JSResponseView(props: Props) {
const { errors, isExecuting, jsObject, responses } = props;
const {
currentFunction,
disabled,
errors,
isExecuting,
isLoading,
jsObject,
onButtonClick,
responses,
} = props;
const [responseStatus, setResponseStatus] = useState<JSResponseState>(
JSResponseState.NoResponse,
);
const panelRef: RefObject<HTMLDivElement> = useRef(null);
const dispatch = useDispatch();
const [selectActionId, setSelectActionId] = useState("");
const actionList = jsObject?.actions;
const sortedActionList = actionList && sortBy(actionList, "name");
const response =
selectActionId && selectActionId in responses
? responses[selectActionId]
currentFunction && currentFunction.id && currentFunction.id in responses
? responses[currentFunction.id]
: "";
const isRunning = selectActionId && !!isExecuting[selectActionId];
const errorsList = errors.filter((er) => {
return er.severity === Severity.ERROR;
});
const onDebugClick = useCallback(() => {
AnalyticsUtil.logEvent("OPEN_DEBUGGER", {
@ -200,18 +187,25 @@ function JSResponseView(props: Props) {
});
dispatch(setCurrentTab(DEBUGGER_TAB_KEYS.ERROR_TAB));
}, []);
const [openSettings, setOpenSettings] = useState(false);
const [selectedFunction, setSelectedFunction] = useState<
undefined | JSAction
>(undefined);
const isSelectedFunctionAsync = (id: string) => {
const jsAction = jsObject.actions.find((action) => action.id === id);
if (!!jsAction) {
return jsAction?.actionConfiguration.isAsync;
useEffect(() => {
if (!currentFunction) {
setResponseStatus(JSResponseState.NoResponse);
} else if (isExecuting[currentFunction.id]) {
setResponseStatus(JSResponseState.IsExecuting);
} else if (
!responses.hasOwnProperty(currentFunction.id) &&
!isExecuting.hasOwnProperty(currentFunction.id)
) {
setResponseStatus(JSResponseState.NoResponse);
} else if (
responses.hasOwnProperty(currentFunction.id) &&
responses[currentFunction.id] === undefined
) {
setResponseStatus(JSResponseState.NoReturnValue);
} else if (responses.hasOwnProperty(currentFunction.id)) {
setResponseStatus(JSResponseState.ShowResponse);
}
return false;
};
}, [responses, isExecuting, currentFunction]);
const tabs = [
{
@ -219,8 +213,8 @@ function JSResponseView(props: Props) {
title: "Response",
panelComponent: (
<>
<HelpSection>
{errorsList.length > 0 ? (
{errors.length > 0 && (
<HelpSection>
<StyledCallout
fill
label={
@ -231,98 +225,55 @@ function JSResponseView(props: Props) {
text={createMessage(PARSING_ERROR)}
variant={Variant.danger}
/>
) : (
""
)}
</HelpSection>
<ResponseTabWrapper className={errorsList.length ? "disable" : ""}>
{sortedActionList && !sortedActionList?.length ? (
<NoResponseContainer className="flex items-center">
{createMessage(EMPTY_JS_OBJECT)}
</NoResponseContainer>
) : (
</HelpSection>
)}
<ResponseTabWrapper className={errors.length ? "disable" : ""}>
<ResponseViewer>
<>
<ResponseTabActionsList>
{sortedActionList &&
sortedActionList?.length > 0 &&
sortedActionList.map((action) => {
return (
<ResponseTabAction
className={
action.id === selectActionId ? "active" : ""
}
key={action.id}
onClick={() => {
setSelectActionId(action.id);
}}
>
<JSFunction />{" "}
<div className="function-name">{action.name}</div>
<div className="function-actions">
{action.actionConfiguration.isAsync ? (
<FlagBadge name={"ASYNC"} />
) : (
""
)}
{isSelectedFunctionAsync(action.id) ? (
<FunctionSettings
onClick={() => {
setSelectedFunction(action);
setOpenSettings(true);
}}
/>
) : (
""
)}
<RunFunction
className="run-button"
onClick={() => {
runAction(action);
}}
/>
</div>
</ResponseTabAction>
);
})}
</ResponseTabActionsList>
<ResponseViewer>
{isRunning ? (
<LoadingOverlayScreen theme={props.theme}>
{createMessage(EXECUTING_FUNCTION)}
</LoadingOverlayScreen>
) : !responses.hasOwnProperty(selectActionId) ? (
<NoResponseContainer className="empty">
<Icon name="no-response" />
<Text className="flex items-center" type={TextType.P1}>
{EMPTY_RESPONSE_FIRST_HALF()}
<RunFunction className="response-run" />
{EMPTY_RESPONSE_LAST_HALF()}
</Text>
</NoResponseContainer>
) : (
<ReadOnlyEditor
folding
height={"100%"}
input={{
value: response,
}}
/>
)}
</ResponseViewer>
{openSettings &&
!!selectedFunction &&
isSelectedFunctionAsync(selectedFunction.id) && (
<JSFunctionSettings
action={selectedFunction}
openSettings={openSettings}
toggleSettings={() => {
setOpenSettings(!openSettings);
}}
/>
)}
{responseStatus === JSResponseState.NoResponse && (
<NoResponseContainer>
<Icon name="no-response" />
<Text type={TextType.P1}>
{createMessage(EMPTY_RESPONSE_FIRST_HALF)}
<InlineButton
disabled={disabled}
isLoading={isLoading}
onClick={onButtonClick}
size={Size.medium}
tag="button"
text="Run"
type="button"
/>
{createMessage(EMPTY_JS_RESPONSE_LAST_HALF)}
</Text>
</NoResponseContainer>
)}
{responseStatus === JSResponseState.IsExecuting && (
<LoadingOverlayScreen theme={props.theme}>
{createMessage(EXECUTING_FUNCTION)}
</LoadingOverlayScreen>
)}
{responseStatus === JSResponseState.NoReturnValue && (
<NoReturnValueWrapper>
<Text type={TextType.P1}>
{createMessage(
NO_JS_FUNCTION_RETURN_VALUE,
currentFunction?.name,
)}
</Text>
</NoReturnValueWrapper>
)}
{responseStatus === JSResponseState.ShowResponse && (
<ReadOnlyEditor
folding
height={"100%"}
input={{
value: response,
}}
/>
)}
</>
)}
</ResponseViewer>
</ResponseTabWrapper>
</>
),
@ -339,23 +290,16 @@ function JSResponseView(props: Props) {
},
];
const runAction = (action: JSAction) => {
setSelectActionId(action.id);
const collectionId = getJSCollectionIdFromURL();
dispatch(
startExecutingJSFunction({
collectionName: jsObject?.name || "",
action: action,
collectionId: collectionId || "",
}),
);
};
return (
<ResponseContainer ref={panelRef}>
<Resizer panelRef={panelRef} />
<TabbedViewWrapper>
<EntityBottomTabs defaultIndex={0} tabs={tabs} />
<EntityBottomTabs
containerRef={panelRef}
defaultIndex={0}
expandedHeight={theme.actionsBottomTabInitialHeight}
tabs={tabs}
/>
</TabbedViewWrapper>
</ResponseContainer>
);

View File

@ -652,6 +652,7 @@ const lightShades = [
"#F86A2B",
"#FFDEDE",
"#575757",
"#191919",
] as const;
type ShadeColor = typeof darkShades[number] | typeof lightShades[number];
@ -1373,13 +1374,14 @@ const editorBottomBar = {
const gitSyncModal = {
menuBackgroundColor: Colors.ALABASTER_ALT,
separator: Colors.ALTO2,
closeIcon: "rgba(29, 28, 29, 0.7);",
closeIcon: Colors.SCORPION,
closeIconHover: Colors.COD_GRAY,
};
type GitSyncModalColors = typeof gitSyncModal;
const tabItemBackgroundFill = {
highlightBackground: Colors.Gallery,
highlightTextColor: Colors.CODE_GRAY,
highlightTextColor: Colors.COD_GRAY,
textColor: Colors.CHARCOAL,
};
@ -2612,7 +2614,7 @@ export const light: ColorType = {
},
modal: {
bg: lightShades[11],
headerText: lightShades[10],
headerText: lightShades[20],
iconColor: lightShades[5],
iconBg: lightShades[18],
user: {

View File

@ -0,0 +1,25 @@
export const ECMA_VERSION = 11;
/* Indicates the mode the code should be parsed in.
This influences global strict mode and parsing of import and export declarations.
*/
export enum SourceType {
script = "script",
module = "module",
}
// Each node has an attached type property which further defines
// what all properties can the node have.
// We will just define the ones we are working with
export enum NodeTypes {
MemberExpression = "MemberExpression",
Identifier = "Identifier",
VariableDeclarator = "VariableDeclarator",
FunctionDeclaration = "FunctionDeclaration",
FunctionExpression = "FunctionExpression",
AssignmentPattern = "AssignmentPattern",
Literal = "Literal",
ExportDefaultDeclaration = "ExportDefaultDeclaration",
Property = "Property",
ArrowFunctionExpression = "ArrowFunctionExpression",
}

View File

@ -1,7 +1,7 @@
import { createGlobalStyle } from "styled-components";
import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig";
import { getTypographyByKey, Theme } from "constants/DefaultTheme";
import { LINT_TOOLTIP_JUSTIFIFIED_LEFT_CLASS } from "components/editorComponents/CodeEditor/constants";
import { LINT_TOOLTIP_JUSTIFIED_LEFT_CLASS } from "components/editorComponents/CodeEditor/constants";
export const CodemirrorHintStyles = createGlobalStyle<{
editorTheme: EditorTheme;
@ -259,7 +259,7 @@ export const CodemirrorHintStyles = createGlobalStyle<{
padding: 7px 12px;
border-radius: 0;
&.${LINT_TOOLTIP_JUSTIFIFIED_LEFT_CLASS}{
&.${LINT_TOOLTIP_JUSTIFIED_LEFT_CLASS}{
transform: translate(-100%);
}

View File

@ -1,10 +1,14 @@
import React, { useState } from "react";
import styled from "styled-components";
import { JSCollection } from "entities/JSCollection";
import React, {
ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { JSAction, JSCollection } from "entities/JSCollection";
import CloseEditor from "components/editorComponents/CloseEditor";
import MoreJSCollectionsMenu from "../Explorer/JSActions/MoreJSActionsMenu";
import { TabComponent } from "components/ads/Tabs";
import FormLabel from "components/editorComponents/FormLabel";
import CodeEditor from "components/editorComponents/CodeEditor";
import {
EditorModes,
@ -14,184 +18,259 @@ import {
} from "components/editorComponents/CodeEditor/EditorConfig";
import FormRow from "components/editorComponents/FormRow";
import JSObjectNameEditor from "./JSObjectNameEditor";
import { updateJSCollectionBody } from "actions/jsPaneActions";
import {
setActiveJSAction,
startExecutingJSFunction,
updateJSCollectionBody,
} from "actions/jsPaneActions";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router";
import { ExplorerURLParams } from "../Explorer/helpers";
import JSResponseView from "components/editorComponents/JSResponseView";
import { EVAL_ERROR_PATH } from "utils/DynamicBindingUtils";
import { get } from "lodash";
import { getDataTree } from "selectors/dataTreeSelectors";
import { EvaluationError } from "utils/DynamicBindingUtils";
import { isEmpty, isEqual } from "lodash";
import SearchSnippets from "components/ads/SnippetButton";
import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
import { JSFunctionRun } from "./JSFunctionRun";
import { AppState } from "reducers";
import {
getActiveJSActionId,
getIsExecutingJSAction,
getJSActions,
getJSCollectionParseErrors,
} from "selectors/entitiesSelector";
import {
convertJSActionsToDropdownOptions,
convertJSActionToDropdownOption,
getActionFromJsCollection,
getJSActionOption,
getJSFunctionLineGutter,
JSActionDropdownOption,
} from "./utils";
import { DropdownOnSelect } from "components/ads";
import JSFunctionSettingsView from "./JSFunctionSettings";
import JSObjectHotKeys from "./JSObjectHotKeys";
import {
ActionButtons,
Form,
FormWrapper,
MainConfiguration,
NameWrapper,
SecondaryWrapper,
TabbedViewContainer,
} from "./styledComponents";
const Form = styled.form`
display: flex;
flex-direction: column;
height: calc(
100vh - ${(props) => props.theme.smallHeaderHeight} -
${(props) => props.theme.backBanner}
);
overflow: hidden;
width: 100%;
${FormLabel} {
padding: ${(props) => props.theme.spaces[3]}px;
}
${FormRow} {
${FormLabel} {
padding: 0;
width: 100%;
}
}
`;
const NameWrapper = styled.div`
width: 49%;
display: flex;
align-items: center;
input {
margin: 0;
box-sizing: border-box;
}
`;
const ActionButtons = styled.div`
justify-self: flex-end;
display: flex;
align-items: center;
button:last-child {
margin-left: ${(props) => props.theme.spaces[7]}px;
}
`;
const SecondaryWrapper = styled.div`
display: flex;
flex-direction: column;
height: calc(100% - 50px);
`;
const MainConfiguration = styled.div`
padding: ${(props) => props.theme.spaces[4]}px
${(props) => props.theme.spaces[10]}px 0px
${(props) => props.theme.spaces[10]}px;
`;
export const TabbedViewContainer = styled.div`
flex: 1;
overflow: auto;
position: relative;
height: 100%;
border-top: 2px solid ${(props) => props.theme.colors.apiPane.dividerBg};
${FormRow} {
min-height: auto;
padding: ${(props) => props.theme.spaces[0]}px;
& > * {
margin-right: 0px;
}
}
&&& {
ul.react-tabs__tab-list {
padding: 0px ${(props) => props.theme.spaces[12]}px;
background-color: ${(props) =>
props.theme.colors.apiPane.responseBody.bg};
}
.react-tabs__tab-panel {
height: calc(100% - 36px);
margin-top: 2px;
background-color: ${(props) => props.theme.colors.apiPane.bg};
}
}
`;
interface JSFormProps {
jsAction: JSCollection;
settingsConfig: any;
jsCollection: JSCollection;
}
type Props = JSFormProps;
function JSEditorForm(props: Props) {
function JSEditorForm({ jsCollection: currentJSCollection }: Props) {
const theme = EditorTheme.LIGHT;
const [mainTabIndex, setMainTabIndex] = useState(0);
const dispatch = useDispatch();
const currentJSAction = props.jsAction;
const dataTree = useSelector(getDataTree);
const handleOnChange = (event: string) => {
if (currentJSAction) {
dispatch(updateJSCollectionBody(event, currentJSAction.id));
}
};
const { pageId } = useParams<ExplorerURLParams>();
const getErrors = get(
dataTree,
`${currentJSAction.name}.${EVAL_ERROR_PATH}.body`,
[],
) as EvaluationError[];
const [disableRunFunctionality, setDisableRunFunctionality] = useState(false);
// Currently active response (only changes upon execution)
const [activeResponse, setActiveResponse] = useState<JSAction | null>(null);
const parseErrors = useSelector(
(state: AppState) =>
getJSCollectionParseErrors(state, currentJSCollection.name),
isEqual,
);
const jsActions = useSelector(
(state: AppState) => getJSActions(state, currentJSCollection.id),
isEqual,
);
const activeJSActionId = useSelector((state: AppState) =>
getActiveJSActionId(state, currentJSCollection.id),
);
const activeJSAction = getActionFromJsCollection(
activeJSActionId,
currentJSCollection,
);
const [selectedJSActionOption, setSelectedJSActionOption] = useState<
JSActionDropdownOption
>(getJSActionOption(activeJSAction, jsActions));
const isExecutingCurrentJSAction = useSelector((state: AppState) =>
getIsExecutingJSAction(
state,
currentJSCollection.id,
selectedJSActionOption.data?.id || "",
),
);
// Triggered when there is a change in the code editor
const handleEditorChange = (valueOrEvent: ChangeEvent<any> | string) => {
const value: string =
typeof valueOrEvent === "string"
? valueOrEvent
: valueOrEvent.target.value;
dispatch(updateJSCollectionBody(value, currentJSCollection.id));
};
// Executes JS action
const executeJSAction = (jsAction: JSAction) => {
setActiveResponse(jsAction);
if (jsAction.id !== selectedJSActionOption.data?.id)
setSelectedJSActionOption(convertJSActionToDropdownOption(jsAction));
dispatch(
setActiveJSAction({
jsCollectionId: currentJSCollection.id || "",
jsActionId: jsAction.id || "",
}),
);
dispatch(
startExecutingJSFunction({
collectionName: currentJSCollection.name || "",
action: jsAction,
collectionId: currentJSCollection.id || "",
}),
);
};
const handleActiveActionChange = useCallback(
(jsAction: JSAction) => {
if (!jsAction) return;
// only update when there is a new active action
if (jsAction.id !== selectedJSActionOption.data?.id) {
setSelectedJSActionOption(convertJSActionToDropdownOption(jsAction));
}
},
[selectedJSActionOption],
);
const JSGutters = useMemo(
() =>
getJSFunctionLineGutter(
jsActions,
executeJSAction,
!parseErrors.length,
handleActiveActionChange,
),
[jsActions, parseErrors, handleActiveActionChange],
);
const handleJSActionOptionSelection: DropdownOnSelect = (
value,
dropDownOption: JSActionDropdownOption,
) => {
dropDownOption.data &&
setSelectedJSActionOption(
convertJSActionToDropdownOption(dropDownOption.data),
);
};
const handleRunAction = (
event: React.MouseEvent<HTMLElement, MouseEvent> | KeyboardEvent,
) => {
event.preventDefault();
selectedJSActionOption.data && executeJSAction(selectedJSActionOption.data);
};
useEffect(() => {
if (parseErrors.length || isEmpty(jsActions)) {
setDisableRunFunctionality(true);
} else {
setDisableRunFunctionality(false);
}
setSelectedJSActionOption(getJSActionOption(activeJSAction, jsActions));
}, [parseErrors, jsActions, activeJSActionId]);
return (
<>
<CloseEditor />
<Form>
<MainConfiguration>
<FormRow className="form-row-header">
<NameWrapper className="t--nameOfJSObject">
<JSObjectNameEditor page="JS_PANE" />
</NameWrapper>
<ActionButtons className="t--formActionButtons">
<MoreJSCollectionsMenu
className="t--more-action-menu"
id={currentJSAction.id}
name={currentJSAction.name}
pageId={pageId}
<FormWrapper>
<JSObjectHotKeys runActiveJSFunction={handleRunAction}>
<CloseEditor />
<Form>
<MainConfiguration>
<FormRow className="form-row-header">
<NameWrapper className="t--nameOfJSObject">
<JSObjectNameEditor page="JS_PANE" />
</NameWrapper>
<ActionButtons className="t--formActionButtons">
<MoreJSCollectionsMenu
className="t--more-action-menu"
id={currentJSCollection.id}
name={currentJSCollection.name}
pageId={pageId}
/>
<SearchSnippets
entityId={currentJSCollection?.id}
entityType={ENTITY_TYPE.JSACTION}
/>
<JSFunctionRun
disabled={disableRunFunctionality}
isLoading={isExecutingCurrentJSAction}
jsCollection={currentJSCollection}
onButtonClick={handleRunAction}
onSelect={handleJSActionOptionSelection}
options={convertJSActionsToDropdownOptions(jsActions)}
selected={selectedJSActionOption}
showTooltip={!selectedJSActionOption.data}
/>
</ActionButtons>
</FormRow>
</MainConfiguration>
<SecondaryWrapper>
<TabbedViewContainer isExecuting={isExecutingCurrentJSAction}>
<TabComponent
onSelect={setMainTabIndex}
selectedIndex={mainTabIndex}
tabs={[
{
key: "code",
title: "Code",
panelComponent: (
<CodeEditor
className={"js-editor"}
customGutter={JSGutters}
dataTreePath={`${currentJSCollection.name}.body`}
folding
height={"100%"}
hideEvaluatedValue
input={{
value: currentJSCollection.body,
onChange: handleEditorChange,
}}
mode={EditorModes.JAVASCRIPT}
placeholder="Let's write some code!"
showLightningMenu={false}
showLineNumbers
size={EditorSize.EXTENDED}
tabBehaviour={TabBehaviour.INDENT}
theme={theme}
/>
),
},
{
key: "settings",
title: "Settings",
panelComponent: (
<JSFunctionSettingsView actions={jsActions} />
),
},
]}
/>
<SearchSnippets
entityId={currentJSAction?.id}
entityType={ENTITY_TYPE.JSACTION}
/>
</ActionButtons>
</FormRow>
</MainConfiguration>
<SecondaryWrapper>
<TabbedViewContainer>
<TabComponent
onSelect={setMainTabIndex}
selectedIndex={mainTabIndex}
tabs={[
{
key: "code",
title: "Code",
panelComponent: (
<CodeEditor
className={"js-editor"}
dataTreePath={`${currentJSAction.name}.body`}
folding
height={"100%"}
hideEvaluatedValue
input={{
value: currentJSAction.body,
onChange: (event: any) => handleOnChange(event),
}}
mode={EditorModes.JAVASCRIPT}
placeholder="Let's write some code!"
showLightningMenu={false}
showLineNumbers
size={EditorSize.EXTENDED}
tabBehaviour={TabBehaviour.INDENT}
theme={theme}
/>
),
},
]}
</TabbedViewContainer>
<JSResponseView
currentFunction={activeResponse}
disabled={disableRunFunctionality}
errors={parseErrors}
isLoading={isExecutingCurrentJSAction}
jsObject={currentJSCollection}
onButtonClick={handleRunAction}
theme={theme}
/>
</TabbedViewContainer>
<JSResponseView
errors={getErrors}
jsObject={currentJSAction}
theme={theme}
/>
</SecondaryWrapper>
</Form>
</>
</SecondaryWrapper>
</Form>
</JSObjectHotKeys>
</FormWrapper>
);
}

View File

@ -0,0 +1,96 @@
import React from "react";
import styled from "styled-components";
import Dropdown, {
DropdownOnSelect,
DropdownContainer,
} from "components/ads/Dropdown";
import Button from "components/ads/Button";
import FlagBadge from "components/utils/FlagBadge";
import { JSCollection } from "entities/JSCollection";
import Tooltip from "components/ads/Tooltip";
import { createMessage, NO_JS_FUNCTION_TO_RUN } from "ce/constants/messages";
import { StyledButton } from "components/ads/Button";
import { JSActionDropdownOption } from "./utils";
import { RUN_BUTTON_DEFAULTS, testLocators } from "./constants";
type Props = {
disabled: boolean;
isLoading: boolean;
jsCollection: JSCollection;
onButtonClick: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
onSelect: DropdownOnSelect;
options: JSActionDropdownOption[];
selected: JSActionDropdownOption;
showTooltip: boolean;
};
export type DropdownWithCTAWrapperProps = {
isDisabled: boolean;
};
const disabledStyles = `
opacity: 0.5;
pointer-events:none;
`;
const DropdownWithCTAWrapper = styled.div<DropdownWithCTAWrapperProps>`
display: flex;
${StyledButton} {
margin-left: ${RUN_BUTTON_DEFAULTS.GAP_SIZE};
padding: 0px 20px;
${(props) =>
props.isDisabled &&
`
${disabledStyles}
`}
}
${DropdownContainer} {
${(props) =>
props.isDisabled &&
`
${disabledStyles}
`}
}
`;
export function JSFunctionRun({
disabled,
isLoading,
jsCollection,
onButtonClick,
onSelect,
options,
selected,
showTooltip,
}: Props) {
return (
<DropdownWithCTAWrapper isDisabled={disabled}>
<Dropdown
customBadge={<FlagBadge name="Async" />}
height={RUN_BUTTON_DEFAULTS.HEIGHT}
onSelect={onSelect}
options={options}
selected={selected}
selectedHighlightBg={RUN_BUTTON_DEFAULTS.DROPDOWN_HIGHLIGHT_BG}
showLabelOnly
truncateOption
/>
<Tooltip
content={createMessage(NO_JS_FUNCTION_TO_RUN, jsCollection.name)}
disabled={!showTooltip}
hoverOpenDelay={50}
>
<Button
className={testLocators.runJSAction}
height={RUN_BUTTON_DEFAULTS.HEIGHT}
isLoading={isLoading}
onClick={onButtonClick}
tag="button"
text={RUN_BUTTON_DEFAULTS.CTA_TEXT}
/>
</Tooltip>
</DropdownWithCTAWrapper>
);
}

View File

@ -1,79 +1,204 @@
import React from "react";
import styled from "styled-components";
import Checkbox from "components/ads/Checkbox";
import Dialog from "components/ads/DialogComponent";
import { JSAction } from "entities/JSCollection";
import { updateFunctionProperty } from "actions/jsPaneActions";
import { useDispatch } from "react-redux";
import {
ASYNC_FUNCTION_SETTINGS_HEADING,
createMessage,
JS_SETTINGS_ONPAGELOAD,
JS_SETTINGS_ONPAGELOAD_SUBTEXT,
JS_SETTINGS_CONFIRM_EXECUTION,
JS_SETTINGS_CONFIRM_EXECUTION_SUBTEXT,
} from "@appsmith/constants/messages";
NO_ASYNC_FUNCTIONS,
} from "ce/constants/messages";
import { AppIcon, Radio, RadioComponent } from "components/ads";
import TooltipComponent from "components/ads/Tooltip";
import { JSAction } from "entities/JSCollection";
import React, { useState } from "react";
import { useDispatch } from "react-redux";
import styled from "styled-components";
import { RADIO_OPTIONS, SETTINGS_HEADINGS } from "./constants";
const FormRow = styled.div`
margin-bottom: ${(props) => props.theme.spaces[10] + 1}px;
&.flex {
display: flex;
align-items: center;
.cs-text {
margin-right: 30px;
color: rgb(9, 7, 7);
}
type SettingsHeadingProps = {
text: string;
hasInfo?: boolean;
info?: string;
grow: boolean;
};
type SettingsItemProps = {
action: JSAction;
};
type JSFunctionSettingsProps = {
actions: JSAction[];
};
const SettingRow = styled.div<{ isHeading?: boolean; noBorder?: boolean }>`
display: flex;
padding: 8px;
${(props) =>
!props.noBorder &&
`
border-bottom: solid 1px ${props.theme.colors.table.border}};
`}
${(props) =>
props.isHeading &&
`
background: #f8f8f8;
font-size: ${props.theme.typography.h5.fontSize}px;
`};
`;
const StyledIcon = styled(AppIcon)`
width: max-content;
height: max-content;
& > svg {
width: 13px;
height: auto;
}
`;
interface JSFunctionSettingsProps {
action: JSAction;
openSettings: boolean;
toggleSettings: () => void;
const SettingColumn = styled.div<{ grow?: boolean; isHeading?: boolean }>`
display: flex;
align-items: center;
flex-grow: ${(props) => (props.grow ? 1 : 0)};
padding: 5px 12px;
min-width: 250px;
${(props) =>
props.isHeading &&
`
text-transform: uppercase;
font-weight: ${props.theme.fontWeights[2]};
font-size: ${props.theme.fontSizes[2]}px
margin-right: 9px;
`}
${StyledIcon} {
margin-left: 8px;
}
${Radio} {
margin-right: 20px;
}
`;
const JSFunctionSettingsWrapper = styled.div`
display: flex;
height: 100%;
border-bottom: 1px solid ${(props) => props.theme.colors.apiPane.dividerBg};
border-top: 1px solid ${(props) => props.theme.colors.apiPane.dividerBg};
`;
const SettingsContainer = styled.div`
display: flex;
flex-direction: column;
padding: 0px ${(props) => props.theme.spaces[13] - 2}px;
width: max-content;
min-width: 700px;
height: 100%;
& > h3 {
margin: 20px 0;
text-transform: capitalize;
font-size: ${(props) => props.theme.fontSizes[5]}px;
font-weight: ${(props) => props.theme.fontWeights[2]};
}
`;
function SettingsHeading({ grow, hasInfo, info, text }: SettingsHeadingProps) {
return (
<SettingColumn grow={grow} isHeading>
<span>{text}</span>
{hasInfo && info && (
<TooltipComponent content={createMessage(() => info)}>
<StyledIcon name="help" />
</TooltipComponent>
)}
</SettingColumn>
);
}
function JSFunctionSettings(props: JSFunctionSettingsProps) {
const { action } = props;
function SettingsItem({ action }: SettingsItemProps) {
const dispatch = useDispatch();
const [executeOnPageLoad, setExecuteOnPageLoad] = useState(
String(!!action.executeOnLoad),
);
const [confirmBeforeExecute, setConfirmBeforeExecute] = useState(
String(!!action.confirmBeforeExecute),
);
const updateProperty = (value: boolean | number, propertyName: string) => {
dispatch(
updateFunctionProperty({
action: props.action,
action: action,
propertyName: propertyName,
value: value,
}),
);
};
const onChangeExecuteOnPageLoad = (value: string) => {
setExecuteOnPageLoad(value);
updateProperty(value === "true", "executeOnLoad");
};
const onChangeConfirmBeforeExecute = (value: string) => {
setConfirmBeforeExecute(value);
updateProperty(value === "true", "confirmBeforeExecute");
};
return (
<Dialog
canOutsideClickClose
isOpen={props.openSettings}
onClose={props.toggleSettings}
title={`Function settings - ${props.action.name}`}
>
<FormRow>
<Checkbox
fill={false}
info={createMessage(JS_SETTINGS_ONPAGELOAD_SUBTEXT)}
isDefaultChecked={action.executeOnLoad}
label={createMessage(JS_SETTINGS_ONPAGELOAD)}
onCheckChange={(value: boolean) =>
updateProperty(value, "executeOnLoad")
}
<SettingRow>
<SettingColumn grow>
<span>{action.name}</span>
</SettingColumn>
<SettingColumn className={`${action.name}-on-page-load-setting`}>
<RadioComponent
backgroundColor="#191919"
defaultValue={executeOnPageLoad}
name={`execute-on-page-load-${action.id}`}
onSelect={onChangeExecuteOnPageLoad}
options={RADIO_OPTIONS}
/>
</FormRow>
<FormRow>
<Checkbox
fill={false}
info={createMessage(JS_SETTINGS_CONFIRM_EXECUTION_SUBTEXT)}
isDefaultChecked={action.confirmBeforeExecute}
label={createMessage(JS_SETTINGS_CONFIRM_EXECUTION)}
onCheckChange={(value: boolean) =>
updateProperty(value, "confirmBeforeExecute")
}
</SettingColumn>
<SettingColumn className={`${action.name}-confirm-before-execute`}>
<RadioComponent
backgroundColor="#191919"
defaultValue={confirmBeforeExecute}
name={`confirm-before-execute-${action.id}`}
onSelect={onChangeConfirmBeforeExecute}
options={RADIO_OPTIONS}
/>
</FormRow>
</Dialog>
</SettingColumn>
</SettingRow>
);
}
export default JSFunctionSettings;
function JSFunctionSettingsView({ actions }: JSFunctionSettingsProps) {
const asyncActions = actions.filter(
(action) => action.actionConfiguration.isAsync,
);
return (
<JSFunctionSettingsWrapper>
<SettingsContainer>
<h3>{createMessage(ASYNC_FUNCTION_SETTINGS_HEADING)}</h3>
<SettingRow isHeading>
{SETTINGS_HEADINGS.map((setting, index) => (
<SettingsHeading
grow={index === 0}
hasInfo={setting.hasInfo}
info={setting.info}
key={setting.key}
text={setting.text}
/>
))}
</SettingRow>
{asyncActions && asyncActions.length ? (
asyncActions.map((action) => (
<SettingsItem action={action} key={action.id} />
))
) : (
<SettingRow noBorder>
<SettingColumn>{createMessage(NO_ASYNC_FUNCTIONS)}</SettingColumn>
</SettingRow>
)}
</SettingsContainer>
</JSFunctionSettingsWrapper>
);
}
export default JSFunctionSettingsView;

View File

@ -0,0 +1,38 @@
import React from "react";
import { Hotkey, Hotkeys } from "@blueprintjs/core";
import { HotkeysTarget } from "@blueprintjs/core/lib/esnext/components/hotkeys/hotkeysTarget.js";
import { JS_OBJECT_HOTKEYS_CLASSNAME } from "./constants";
type Props = {
runActiveJSFunction: (e: KeyboardEvent) => void;
children: React.ReactNode;
};
@HotkeysTarget
class JSObjectHotKeys extends React.Component<Props> {
public renderHotkeys() {
return (
<Hotkeys>
<Hotkey
allowInInput
combo="mod + enter"
global
label="Run Js Function"
onKeyDown={this.props.runActiveJSFunction}
/>
</Hotkeys>
);
}
render() {
/*
Blueprint's v3 decorated component must return a single DOM element in its render() method, not a custom React component.
This constraint allows HotkeysTarget to inject event handlers without creating an extra wrapper element.
*/
return (
<div className={JS_OBJECT_HOTKEYS_CLASSNAME}>{this.props.children}</div>
);
}
}
export default JSObjectHotKeys;

View File

@ -0,0 +1,81 @@
import { OptionProps } from "components/ads";
import { css } from "styled-components";
import { JSActionDropdownOption } from "./utils";
export const RUN_BUTTON_DEFAULTS = {
HEIGHT: "30px",
CTA_TEXT: "RUN",
// space between button and dropdown
GAP_SIZE: "10px",
DROPDOWN_HIGHLIGHT_BG: "#E7E7E7",
};
export const NO_SELECTION_DROPDOWN_OPTION: JSActionDropdownOption = {
label: "No function selected",
value: "",
data: null,
};
export const NO_FUNCTION_DROPDOWN_OPTION: JSActionDropdownOption = {
label: "No function available",
value: "",
data: null,
};
export const SETTINGS_HEADINGS = [
{
text: "Function Name",
hasInfo: false,
key: "func_name",
},
{
text: "Run on page load",
hasInfo: true,
info: "Allow function run when page loads",
key: "run_on_pageload",
},
{
text: "Confirm before calling ",
hasInfo: true,
info: "Ask for confirmation before executing function",
key: "run_before_calling",
},
];
export const RADIO_OPTIONS: OptionProps[] = [
{
label: "Yes",
value: "true",
},
{
label: "No",
value: "false",
},
];
export const RUN_GUTTER_ID = "run-gutter";
export const RUN_GUTTER_CLASSNAME = "run-marker-gutter";
export const JS_OBJECT_HOTKEYS_CLASSNAME = "js-object-hotkeys";
export const ANIMATE_RUN_GUTTER = "animate-run-marker";
export const testLocators = {
runJSAction: "run-js-action",
};
export const CodeEditorWithGutterStyles = css`
.${RUN_GUTTER_ID} {
width: 0.5em;
background: #f0f0f0;
margin-left: 5px;
}
.${RUN_GUTTER_CLASSNAME} {
cursor: pointer;
color: #f86a2b;
}
.CodeMirror-linenumbers {
width: max-content;
}
.CodeMirror-linenumber {
text-align: right;
padding-left: 0;
}
.cm-s-duotone-light.CodeMirror {
padding: 0;
}
`;

View File

@ -9,16 +9,13 @@ import { getJSCollectionById } from "selectors/editorSelectors";
import CenteredWrapper from "components/designSystems/appsmith/CenteredWrapper";
import Spinner from "components/editorComponents/Spinner";
import styled from "styled-components";
import { getPluginSettingConfigs } from "selectors/entitiesSelector";
import _ from "lodash";
const LoadingContainer = styled(CenteredWrapper)`
height: 50%;
`;
interface ReduxStateProps {
jsAction: JSCollection | undefined;
jsCollection: JSCollection | undefined;
isCreating: boolean;
settingsConfig: any;
}
type Props = ReduxStateProps &
@ -26,7 +23,7 @@ type Props = ReduxStateProps &
class JSEditor extends React.Component<Props> {
render() {
const { isCreating, jsAction, settingsConfig } = this.props;
const { isCreating, jsCollection } = this.props;
if (isCreating) {
return (
<LoadingContainer>
@ -34,24 +31,19 @@ class JSEditor extends React.Component<Props> {
</LoadingContainer>
);
}
if (!!jsAction) {
return (
<JsEditorForm jsAction={jsAction} settingsConfig={settingsConfig} />
);
if (!!jsCollection) {
return <JsEditorForm jsCollection={jsCollection} />;
}
return null;
}
}
const mapStateToProps = (state: AppState, props: Props): ReduxStateProps => {
const jsAction = getJSCollectionById(state, props);
const jsCollection = getJSCollectionById(state, props);
const { isCreating } = state.ui.jsPane;
const pluginId = _.get(jsAction, "pluginId", "");
const settingsConfig = getPluginSettingConfigs(state, pluginId);
return {
jsAction,
settingsConfig,
jsCollection,
isCreating: isCreating,
};
};

View File

@ -0,0 +1,9 @@
## Naming conventions
- jsCollection : refers to individual JS Object configs
- jsAction : refers to actions derived from functions within a jsCollection
## To do
- Optimize number of Ast calls

View File

@ -0,0 +1,132 @@
import styled, { css } from "styled-components";
import FormRow from "components/editorComponents/FormRow";
import FormLabel from "components/editorComponents/FormLabel";
import {
JS_OBJECT_HOTKEYS_CLASSNAME,
RUN_GUTTER_CLASSNAME,
RUN_GUTTER_ID,
} from "./constants";
export const CodeEditorWithGutterStyles = css`
.${RUN_GUTTER_ID} {
width: 0.5em;
background: #f0f0f0;
margin-left: 5px;
}
.${RUN_GUTTER_CLASSNAME} {
cursor: pointer;
color: #f86a2b;
}
.CodeMirror-linenumbers {
width: max-content;
}
.CodeMirror-linenumber {
text-align: right;
padding-left: 0;
}
.cm-s-duotone-light.CodeMirror {
padding: 0;
}
`;
export const FormWrapper = styled.div`
height: ${({ theme }) =>
`calc(100vh - ${theme.smallHeaderHeight} - ${theme.backBanner})`};
overflow: hidden;
.${JS_OBJECT_HOTKEYS_CLASSNAME} {
width: 100%;
height: 100%;
}
`;
export const Form = styled.form`
display: flex;
flex-direction: column;
height: ${({ theme }) => `calc(100% - ${theme.backBanner})`};
overflow: hidden;
${FormLabel} {
padding: ${(props) => props.theme.spaces[3]}px;
}
${FormRow} {
${FormLabel} {
padding: 0;
width: 100%;
}
}
.t--no-binding-prompt {
display: none;
}
`;
export const NameWrapper = styled.div`
width: 49%;
display: flex;
align-items: center;
input {
margin: 0;
box-sizing: border-box;
}
`;
export const ActionButtons = styled.div`
justify-self: flex-end;
display: flex;
align-items: center;
button:last-child {
margin: 0 ${(props) => props.theme.spaces[7]}px;
height: 30px;
}
`;
export const SecondaryWrapper = styled.div`
display: flex;
flex-direction: column;
height: calc(100% - 50px);
overflow: hidden;
`;
export const MainConfiguration = styled.div`
padding: ${(props) => props.theme.spaces[4]}px
${(props) => props.theme.spaces[10]}px 0px
${(props) => props.theme.spaces[10]}px;
`;
export const TabbedViewContainer = styled.div<{ isExecuting: boolean }>`
flex: 1;
overflow: auto;
position: relative;
border-top: 2px solid ${(props) => props.theme.colors.apiPane.dividerBg};
${FormRow} {
min-height: auto;
padding: ${(props) => props.theme.spaces[0]}px;
& > * {
margin-right: 0px;
}
}
&&& {
ul.react-tabs__tab-list {
padding: 0px ${(props) => props.theme.spaces[12]}px;
background-color: ${(props) =>
props.theme.colors.apiPane.responseBody.bg};
}
.react-tabs__tab-panel {
${CodeEditorWithGutterStyles}
height: calc(100% - 36px);
margin-top: 2px;
background-color: ${(props) => props.theme.colors.apiPane.bg};
.CodeEditorTarget {
border-bottom: 1px solid
${(props) => props.theme.colors.apiPane.dividerBg};
outline: none;
}
${(props) =>
props.isExecuting &&
`
.${RUN_GUTTER_CLASSNAME} {
cursor: progress;
}
`}
}
}
`;

View File

@ -0,0 +1,164 @@
import { JSAction } from "entities/JSCollection";
import { uniqueId } from "lodash";
import { NO_FUNCTION_DROPDOWN_OPTION } from "./constants";
import {
convertJSActionToDropdownOption,
getJSActionOption,
getJSFunctionStartLineFromCode,
isCursorWithinNode,
} from "./utils";
const BASE_JS_OBJECT_BODY = `export default {
myVar1: [],
myVar2: {},
myFun1: () => {
//write code here
return FilePicker1
},
myFun2: async () => {
//use async-await or promises
await Api3.run()
await Api3.run()
return Api3.data
}
}`;
const BASE_JS_OBJECT_BODY_WITH_LITERALS = `export default {
myVar1: [],
myVar2: {},
["myFun1"]: () => {
//write code here
return FilePicker1
},
["myFun2"]: async () => {
//use async-await or promises
await Api3.run()
await Api3.run()
return Api3.data
}
}`;
const BASE_JS_ACTION = (useLiterals = false) => {
return {
organizationId: "organization-id",
pageId: "page-id",
collectionId: "collection-id",
pluginId: "plugin-id",
executeOnLoad: false,
dynamicBindingPathList: [],
isValid: false,
invalids: [],
jsonPathKeys: [],
cacheResponse: "",
confirmBeforeExecute: false,
messages: [],
clientSideExecution: false,
actionConfiguration: {
body: useLiterals
? BASE_JS_OBJECT_BODY_WITH_LITERALS
: BASE_JS_OBJECT_BODY,
isAsync: true,
timeoutInMillisecond: 1000,
jsArguments: [],
},
};
};
const createJSAction = (name: string, useLiterals = false): JSAction => {
return {
...BASE_JS_ACTION(useLiterals),
id: uniqueId(name),
name,
};
};
describe("getJSFunctionStartLineFromCode", () => {
it("returns null when cursor isn't within any function", () => {
const actualResponse = getJSFunctionStartLineFromCode(
BASE_JS_OBJECT_BODY,
100,
);
const expectedResponse = null;
expect(actualResponse).toStrictEqual(expectedResponse);
});
it("returns correct start line of function", () => {
const actualResponse1 = getJSFunctionStartLineFromCode(
BASE_JS_OBJECT_BODY,
4,
);
const actualResponse2 = getJSFunctionStartLineFromCode(
BASE_JS_OBJECT_BODY,
9,
);
const expectedStartLine1 = 3; // startLine of myFun1
const expectedStartLine2 = 7; // startLine of myFun2
expect(actualResponse1?.line).toStrictEqual(expectedStartLine1);
expect(actualResponse2?.line).toStrictEqual(expectedStartLine2);
});
it("returns correct start line of function when object keys are literals", () => {
const actualResponse1 = getJSFunctionStartLineFromCode(
BASE_JS_OBJECT_BODY_WITH_LITERALS,
4,
);
const actualResponse2 = getJSFunctionStartLineFromCode(
BASE_JS_OBJECT_BODY_WITH_LITERALS,
9,
);
const expectedStartLine1 = 3; // startLine of myFun1
const expectedStartLine2 = 7; // startLine of myFun2
expect(actualResponse1?.line).toStrictEqual(expectedStartLine1);
expect(actualResponse2?.line).toStrictEqual(expectedStartLine2);
});
it("isCursorWithinNode returns correct value", () => {
const cursorLineNumber = 2;
const testNodeLocation1 = {
start: { line: 1, column: 1, offset: 0 },
end: { line: 6, column: 1, offset: 0 },
};
const testNodeLocation2 = {
start: { line: 4, column: 1, offset: 0 },
end: { line: 6, column: 1, offset: 0 },
};
const actualResponse1 = isCursorWithinNode(
testNodeLocation1,
cursorLineNumber,
);
const actualResponse2 = isCursorWithinNode(
testNodeLocation2,
cursorLineNumber,
);
expect(actualResponse1).toBeTruthy();
expect(actualResponse2).toBeFalsy();
});
});
describe("jsAction dropdown", () => {
const jsActions = [
createJSAction("myFun1"),
createJSAction("myFun2"),
createJSAction("myFun3"),
];
it("getJSActionOption returns active JS Action on priority", () => {
const activeJSAction = jsActions[0];
const actualResponse = getJSActionOption(activeJSAction, jsActions);
const expectedResponse = convertJSActionToDropdownOption(activeJSAction);
expect(actualResponse).toEqual(expectedResponse);
});
it("getJSActionOption returns default option when there is no jsAction present", () => {
const activeJSAction = null;
const actualResponse = getJSActionOption(activeJSAction, []);
const expectedResponse = NO_FUNCTION_DROPDOWN_OPTION;
expect(actualResponse).toEqual(expectedResponse);
});
});

View File

@ -0,0 +1,160 @@
import { parse, Node } from "acorn";
import { ancestor } from "acorn-walk";
import { CodeEditorGutter } from "components/editorComponents/CodeEditor";
import { JSAction, JSCollection } from "entities/JSCollection";
import {
RUN_GUTTER_CLASSNAME,
RUN_GUTTER_ID,
NO_FUNCTION_DROPDOWN_OPTION,
} from "./constants";
import { DropdownOption } from "components/ads/Dropdown";
import { find, memoize, sortBy } from "lodash";
import { ECMA_VERSION, NodeTypes, SourceType } from "constants/ast";
import { isLiteralNode, isPropertyNode, PropertyNode } from "workers/ast";
export interface JSActionDropdownOption extends DropdownOption {
data: JSAction | null;
}
export const getAST = memoize((code: string, sourceType: SourceType) =>
parse(code, {
ecmaVersion: ECMA_VERSION,
sourceType: sourceType,
locations: true, // Adds location data to each node
}),
);
export const isCursorWithinNode = (
nodeLocation: acorn.SourceLocation,
cursorLineNumber: number,
) => {
return (
nodeLocation.start.line <= cursorLineNumber &&
nodeLocation.end.line >= cursorLineNumber
);
};
const getNameFromPropertyNode = (node: PropertyNode): string =>
isLiteralNode(node.key) ? String(node.key.value) : node.key.name;
// Function to get start line of js function from code, returns null if function not found
export const getJSFunctionStartLineFromCode = (
code: string,
cursorLine: number,
): { line: number; actionName: string } | null => {
let ast: Node = { end: 0, start: 0, type: "" };
let result: { line: number; actionName: string } | null = null;
try {
ast = getAST(code, SourceType.module);
} catch (e) {
return result;
}
ancestor(ast, {
Property(node, ancestors: Node[]) {
// We are only interested in identifiers at this depth (exported object keys)
const depth = ancestors.length - 3;
if (
isPropertyNode(node) &&
(node.value.type === NodeTypes.ArrowFunctionExpression ||
node.value.type === NodeTypes.FunctionExpression) &&
node.loc &&
isCursorWithinNode(node.loc, cursorLine + 1) &&
ancestors[depth] &&
ancestors[depth].type === NodeTypes.ExportDefaultDeclaration
) {
// 1 is subtracted because codeMirror's line is zero-indexed, this isn't
result = {
line: node.loc.start.line - 1,
actionName: getNameFromPropertyNode(node),
};
}
},
});
return result;
};
export const createGutterMarker = (gutterOnclick: () => void) => {
const marker = document.createElement("button");
marker.innerHTML = "&#9654;";
marker.classList.add(RUN_GUTTER_CLASSNAME);
marker.onmousedown = function(e) {
e.preventDefault();
gutterOnclick();
};
// Allows executing functions (via run gutter) when devtool is open
marker.ontouchstart = function(e) {
e.preventDefault();
gutterOnclick();
};
return marker;
};
export const getJSFunctionLineGutter = (
jsActions: JSAction[],
runFuction: (jsAction: JSAction) => void,
showGutters: boolean,
onFocusAction: (jsAction: JSAction) => void,
): CodeEditorGutter => {
const gutter: CodeEditorGutter = {
getGutterConfig: null,
gutterId: RUN_GUTTER_ID,
};
if (!showGutters || !jsActions.length) return gutter;
return {
getGutterConfig: (code: string, lineNumber: number) => {
const config = getJSFunctionStartLineFromCode(code, lineNumber);
const action = find(jsActions, ["name", config?.actionName]);
return config && action
? {
line: config.line,
element: createGutterMarker(() => runFuction(action)),
isFocusedAction: () => {
onFocusAction(action);
},
}
: null;
},
gutterId: RUN_GUTTER_ID,
};
};
export const convertJSActionsToDropdownOptions = (
JSActions: JSAction[],
): JSActionDropdownOption[] => {
return sortBy(JSActions, ["name"]).map(convertJSActionToDropdownOption);
};
export const convertJSActionToDropdownOption = (
JSAction: JSAction,
): JSActionDropdownOption => ({
label: JSAction.name,
value: JSAction.id,
data: JSAction,
hasCustomBadge: !!JSAction.actionConfiguration.isAsync,
});
export const getActionFromJsCollection = (
actionId: string | null,
jsCollection: JSCollection,
): JSAction | null => {
if (!actionId) return null;
return jsCollection.actions.find((action) => action.id === actionId) || null;
};
/**
* Returns dropdown option based on priority and availability
*/
export const getJSActionOption = (
activeJSAction: JSAction | null,
jsActions: JSAction[],
): JSActionDropdownOption => {
let jsActionOption = NO_FUNCTION_DROPDOWN_OPTION;
if (activeJSAction) {
jsActionOption = convertJSActionToDropdownOption(activeJSAction);
} else if (jsActions.length) {
jsActionOption = convertJSActionToDropdownOption(jsActions[0]);
}
return jsActionOption;
};

View File

@ -79,7 +79,10 @@ import {
ConditionalOutput,
FormEvalOutput,
} from "reducers/evaluationReducers/formEvaluationReducer";
import { responseTabComponent } from "components/editorComponents/ApiResponseView";
import {
responseTabComponent,
InlineButton,
} from "components/editorComponents/ApiResponseView";
const QueryFormContainer = styled.form`
flex: 1;
@ -362,11 +365,6 @@ const TabContainerView = styled.div`
position: relative;
`;
const InlineButton = styled(Button)`
display: inline-flex;
margin: 0 4px;
`;
const Wrapper = styled.div`
display: flex;
flex-direction: row;
@ -895,6 +893,11 @@ export function EditorJSONtoForm(props: Props) {
name={currentActionConfig ? currentActionConfig.name : ""}
pageId={pageId}
/>
<SearchSnippets
className="search-snippets"
entityId={currentActionConfig?.id}
entityType={ENTITY_TYPE.ACTION}
/>
<DropdownSelect>
<DropdownField
className={"t--switch-datasource"}
@ -906,11 +909,6 @@ export function EditorJSONtoForm(props: Props) {
width={232}
/>
</DropdownSelect>
<SearchSnippets
className="search-snippets"
entityId={currentActionConfig?.id}
entityType={ENTITY_TYPE.ACTION}
/>
<Button
className="t--run-query"
data-guided-tour-iid="run-query"

View File

@ -17,6 +17,7 @@ import { useParams } from "react-router";
import classNames from "classnames";
import { forceOpenWidgetPanel } from "actions/widgetSidebarActions";
import { useDispatch } from "react-redux";
import { getCurrentThemeDetails } from "selectors/themeSelectors";
const Container = styled.section`
width: 100%;
@ -38,6 +39,7 @@ function CanvasContainer() {
const isFetchingPage = useSelector(getIsFetchingPage);
const widgets = useSelector(getCanvasWidgetDsl);
const pages = useSelector(getViewModePageList);
const theme = useSelector(getCurrentThemeDetails);
const isPreviewMode = useSelector(previewModeSelector);
const params = useParams<{ applicationId: string; pageId: string }>();
const shouldHaveTopMargin = !isPreviewMode || pages.length > 1;
@ -63,7 +65,9 @@ function CanvasContainer() {
if (!isFetchingPage && widgets) {
node = <Canvas dsl={widgets} pageId={params.pageId} />;
}
// calculating exact height to not allow scroll at this component,
// calculating total height minus margin on top, top bar and bottom bar
const heightWithTopMargin = `calc(100vh - 2.25rem - ${theme.smallHeaderHeight} - ${theme.bottomBarHeight})`;
return (
<Container
className={classNames({
@ -73,7 +77,7 @@ function CanvasContainer() {
})}
key={currentPageId}
style={{
height: `calc(100vh - ${shouldHaveTopMargin ? "2rem" : "0px"})`,
height: shouldHaveTopMargin ? heightWithTopMargin : "100vh",
}}
>
{node}

View File

@ -35,7 +35,6 @@ const Container = styled.div`
flex-direction: column;
position: relative;
overflow-y: hidden;
padding: 0px 8px 0px 8px;
`;
const BodyContainer = styled.div`
@ -49,10 +48,17 @@ const MenuContainer = styled.div`
const CloseBtnContainer = styled.div`
position: absolute;
right: 0;
right: -5px;
top: 0;
padding: ${(props) => props.theme.spaces[1]}px;
padding: ${(props) => props.theme.spaces[1]}px 0;
border-radius: ${(props) => props.theme.radii[1]}px;
&:hover {
svg,
svg path {
fill: ${({ theme }) => get(theme, "colors.gitSyncModal.closeIconHover")};
}
}
`;
const ComponentsByTab = {

View File

@ -0,0 +1,117 @@
import React, { useState } from "react";
import { useSelector } from "react-redux";
import Dialog from "components/ads/DialogComponent";
import styled, { useTheme } from "styled-components";
import Text, { TextType } from "components/ads/Text";
import { Colors } from "constants/Colors";
import {
createMessage,
APPLICATION_IMPORT_SUCCESS,
APPLICATION_IMPORT_SUCCESS_DESCRIPTION,
} from "@appsmith/constants/messages";
import Icon from "components/ads/Icon";
import { Theme } from "constants/DefaultTheme";
import { getCurrentUser } from "selectors/usersSelectors";
import { Button, Category, Size } from "components/ads";
const Container = styled.div`
height: 461px;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
overflow-y: hidden;
justify-content: center;
align-content: center;
`;
const BodyContainer = styled.div`
display: flex;
flex-direction: column;
.cs-icon {
margin: auto;
svg {
width: 68px;
height: 68px;
}
}
.cs-text {
text-align: center;
}
`;
const ActionButtonWrapper = styled.div`
display: flex;
justify-content: center;
margin: 30px 0px 0px;
position: absolute;
width: 100%;
bottom: 0px;
`;
const ActionButton = styled(Button)`
margin-right: 16px;
`;
function ImportedApplicationSuccessModal() {
const importedAppSuccess = localStorage.getItem("importApplicationSuccess");
// const isOpen = importedAppSuccess === "true";
const [isOpen, setIsOpen] = useState(importedAppSuccess === "true");
const currentUser = useSelector(getCurrentUser);
const onClose = () => {
setIsOpen(false);
localStorage.setItem("importApplicationSuccess", "false");
};
const theme = useTheme() as Theme;
return (
<Dialog
canEscapeKeyClose
canOutsideClickClose
className="t--import-app-success-modal"
isOpen={isOpen}
maxWidth={"900px"}
noModalBodyMarginTop
onClose={onClose}
width={"600px"}
>
<Container>
<BodyContainer>
<Icon fillColor={Colors.GREEN_1} name="oval-check-fill" />
<Text
color={Colors.BLACK}
style={{ marginTop: 64 }}
type={TextType.DANGER_HEADING}
weight="bold"
>
{createMessage(
APPLICATION_IMPORT_SUCCESS,
currentUser?.name || currentUser?.username,
)}
</Text>
<Text
color={Colors.GRAY_700}
style={{ marginTop: theme.spaces[3] }}
type={TextType.P1}
>
{createMessage(APPLICATION_IMPORT_SUCCESS_DESCRIPTION)}
</Text>
<ActionButtonWrapper>
<ActionButton
category={Category.primary}
className="t--import-success-modal-got-it"
onClick={() => {
onClose();
}}
size={Size.medium}
text="GOT IT"
/>
</ActionButtonWrapper>
</BodyContainer>
</Container>
</Dialog>
);
}
export default ImportedApplicationSuccessModal;

View File

@ -59,6 +59,7 @@ import { Toaster, Variant } from "components/ads";
import { getOAuthAccessToken } from "actions/datasourceActions";
import { builderURL } from "RouteBuilder";
import { PLACEHOLDER_APP_SLUG } from "constants/routes";
import localStorage from "utils/localStorage";
const Container = styled.div`
height: 765px;
@ -276,13 +277,22 @@ function ReconnectDatasourceModal() {
const isDatasourceTesting = useSelector(getIsDatasourceTesting);
const isDatasourceUpdating = useSelector(getDatasourceLoading);
// checking refresh modal
const pendingApp = JSON.parse(
localStorage.getItem("importedAppPendingInfo") || "null",
);
// getting query from redirection url
const userOrgs = useSelector(getUserApplicationsOrgsList);
const queryParams = useQuery();
const queryAppId = queryParams.get("appId");
const queryPageId = queryParams.get("pageId");
const queryDatasourceId = queryParams.get("datasourceId");
const queryIsImport = JSON.parse(queryParams.get("importForGit") ?? "false");
const queryAppId =
queryParams.get("appId") || (pendingApp ? pendingApp.appId : null);
const queryPageId =
queryParams.get("pageId") || (pendingApp ? pendingApp.pageId : null);
const queryDatasourceId =
queryParams.get("datasourceId") ||
(pendingApp ? pendingApp.datasourceId : null);
const queryIsImport =
queryParams.get("importForGit") === "true" || !!pendingApp;
const [selectedDatasourceId, setSelectedDatasourceId] = useState<
string | null
@ -471,7 +481,17 @@ function ReconnectDatasourceModal() {
next = next || pending[0];
setSelectedDatasourceId(next.id);
setDatasource(next);
// when refresh, it should be opened.
const appInfo = {
appId: appId,
pageId: pageId,
datasourceId: next.id,
};
localStorage.setItem("importedAppPendingInfo", JSON.stringify(appInfo));
} else if (appURL) {
// open application import successfule
localStorage.setItem("importApplicationSuccess", "true");
localStorage.setItem("importedAppPendingInfo", "null");
window.open(appURL, "_self");
}
}
@ -557,6 +577,7 @@ function ReconnectDatasourceModal() {
AnalyticsUtil.logEvent(
"RECONNECTING_SKIP_TO_APPLICATION_BUTTON_CLICK",
);
localStorage.setItem("importedAppPendingInfo", "null");
}}
size={Size.medium}
text={createMessage(SKIP_TO_APPLICATION)}

View File

@ -61,7 +61,7 @@ const KeyText = styled.span`
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: ${Colors.CODE_GRAY};
color: ${Colors.COD_GRAY};
`;
const MoreMenuWrapper = styled.div`

View File

@ -6,11 +6,12 @@ export const Title = styled.p`
${(props) => getTypographyByKey(props, "h1")};
margin: ${(props) =>
`${props.theme.spaces[7]}px 0px ${props.theme.spaces[3]}px 0px`};
color: ${Colors.COD_GRAY};
`;
export const Subtitle = styled.span`
${(props) => getTypographyByKey(props, "p1")};
color: ${Colors.BLACK};
color: ${Colors.COD_GRAY};
`;
export const Caption = styled.span`

View File

@ -52,6 +52,7 @@ import { loading } from "selectors/onboardingSelectors";
import GuidedTourModal from "./GuidedTour/DeviationModal";
import { getPageLevelSocketRoomId } from "sagas/WebsocketSagas/utils";
import RepoLimitExceededErrorModal from "./gitSync/RepoLimitExceededErrorModal";
import ImportedApplicationSuccessModal from "./gitSync/ImportedAppSuccessModal";
type EditorProps = {
currentApplicationId?: string;
@ -229,6 +230,7 @@ class Editor extends Component<Props> {
<ConcurrentPageEditorToast />
<GuidedTourModal />
<RepoLimitExceededErrorModal />
<ImportedApplicationSuccessModal />
</GlobalHotKeys>
</div>
<RequestConfirmationModal />

View File

@ -54,6 +54,14 @@ export function DisconnectService(props: {
warning: string;
}) {
const [warnDisconnectAuth, setWarnDisconnectAuth] = useState(false);
const [disconnectCalled, setDisconnectCalled] = useState(false);
const callDisconnect = () => {
if (!disconnectCalled) {
setDisconnectCalled(true);
props.disconnect();
}
};
return (
<Container>
@ -63,7 +71,7 @@ export function DisconnectService(props: {
<DisconnectButton
data-testid="disconnect-service-button"
onClick={() =>
warnDisconnectAuth ? props.disconnect() : setWarnDisconnectAuth(true)
warnDisconnectAuth ? callDisconnect() : setWarnDisconnectAuth(true)
}
text={
warnDisconnectAuth

View File

@ -20,16 +20,15 @@ const StyledIcon = styled(Icon)`
export const StyledFormGroup = styled.div`
width: 40rem;
margin-bottom: ${(props) => props.theme.spaces[7]}px;
& span.bp3-popover-target {
display: inline-block;
background: ${(props) => props.theme.colors.menuItem.normalIcon};
border-radius: ${(props) => props.theme.radii[2]}px;
width: 14px;
padding: 3px 3px;
position: relative;
top: -2px;
left: 6px;
cursor: default;
&.t--admin-settings-dropdown {
div {
width: 100%;
&:hover {
&:hover {
background-color: ${(props) => props.theme.colors.textInput.hover.bg};
}
}
}
}
& svg:hover {
cursor: default;

View File

@ -0,0 +1,28 @@
import React from "react";
import { FormGroup, SettingComponentProps } from "./Common";
import SelectField from "components/ads/formFields/SelectField";
export default function DropDown(
props: {
dropdownOptions: Array<{ id: string; value: string; label?: string }>;
} & SettingComponentProps,
) {
const { dropdownOptions, setting } = props;
return (
<FormGroup
className={`t--admin-settings-dropdown t--admin-settings-${setting.name ||
setting.id}`}
setting={setting}
>
<SelectField
fillOptions
name={setting.id}
options={dropdownOptions}
outline={false}
placeholder="Select an option"
size="large"
/>
</FormGroup>
);
}

View File

@ -20,6 +20,7 @@ import { Callout } from "components/ads/CalloutV2";
import { CopyUrlReduxForm } from "components/ads/formFields/CopyUrlForm";
import Accordion from "./Accordion";
import TagInputField from "./TagInputField";
import Dropdown from "./Dropdown";
import { Classes } from "@blueprintjs/core";
import { Colors } from "constants/Colors";
@ -228,6 +229,21 @@ export default function Group({
/>
</div>
);
case SettingTypes.DROPDOWN:
return (
<div
className={setting.isHidden ? "hide" : ""}
data-testid="admin-settings-dropdown"
key={setting.name || setting.id}
>
{setting.dropdownOptions && (
<Dropdown
dropdownOptions={setting.dropdownOptions}
setting={setting}
/>
)}
</div>
);
}
})}
</GroupBody>

View File

@ -25,14 +25,17 @@ import {
import { DisconnectService } from "./DisconnectService";
import {
createMessage,
DISCONNECT_AUTH_ERROR,
DISCONNECT_SERVICE_SUBHEADER,
DISCONNECT_SERVICE_WARNING,
MANDATORY_FIELDS_ERROR,
} from "@appsmith/constants/messages";
import { Toaster, Variant } from "components/ads";
import {
connectedMethods,
saveAllowed,
} from "@appsmith/utils/adminSettingsHelpers";
import AnalyticsUtil from "utils/AnalyticsUtil";
const Wrapper = styled.div`
flex-basis: calc(100% - ${(props) => props.theme.homePage.leftPane.width}px);
@ -103,13 +106,19 @@ export function SettingsForm(
const onSave = () => {
if (checkMandatoryFileds()) {
if (saveAllowed(props.settings)) {
AnalyticsUtil.logEvent("ADMIN_SETTINGS_SAVE", {
method: pageTitle,
});
dispatch(saveSettings(props.settings));
} else {
saveBlocked();
}
} else {
AnalyticsUtil.logEvent("ADMIN_SETTINGS_ERROR", {
error: createMessage(MANDATORY_FIELDS_ERROR),
});
Toaster.show({
text: "Mandatory fields cannot be empty",
text: createMessage(MANDATORY_FIELDS_ERROR),
variant: Variant.danger,
});
}
@ -138,7 +147,12 @@ export function SettingsForm(
return !(requiredFields.length > 0);
};
const onClear = () => {
const onClear = (event?: React.FocusEvent<any, any>) => {
if (event?.type === "click") {
AnalyticsUtil.logEvent("ADMIN_SETTINGS_RESET", {
method: pageTitle,
});
}
_.forEach(props.settingsConfig, (value, settingName) => {
const setting = AdminConfig.settingsMap[settingName];
if (setting && setting.controlType == SettingTypes.TOGGLE) {
@ -159,8 +173,11 @@ export function SettingsForm(
}, []);
const saveBlocked = () => {
AnalyticsUtil.logEvent("ADMIN_SETTINGS_ERROR", {
error: createMessage(DISCONNECT_AUTH_ERROR),
});
Toaster.show({
text: "Cannot disconnect the only connected authentication method.",
text: createMessage(DISCONNECT_AUTH_ERROR),
variant: Variant.danger,
});
};
@ -174,6 +191,9 @@ export function SettingsForm(
}
});
dispatch(saveSettings(updatedSettings));
AnalyticsUtil.logEvent("ADMIN_SETTINGS_DISCONNECT_AUTH_METHOD", {
method: pageTitle,
});
} else {
saveBlocked();
}

View File

@ -17,25 +17,25 @@ import { ANONYMOUS_USERNAME } from "constants/userConstants";
import { Colors } from "constants/Colors";
import { viewerURL } from "RouteBuilder";
const StyledCopyToClipBoard = styled(CopyToClipBoard)`
margin-bottom: 24px;
`;
const CommonTitleTextStyle = css`
color: ${Colors.CHARCOAL};
font-weight: normal;
`;
const Title = styled.div`
padding: 0 0 10px 0;
padding: 0 0 8px 0;
& > span[type="h5"] {
${CommonTitleTextStyle}
}
`;
const StyledCopyToClipBoard = styled(CopyToClipBoard)`
margin-bottom: 24px;
`;
const ShareWithPublicOption = styled.div`
display: flex;
margin-bottom: 24px;
margin-bottom: 8px;
align-items: center;
justify-content: space-between;

View File

@ -7,7 +7,7 @@ import { Classes } from "components/ads/common";
import { useLocation } from "react-router-dom";
const StyledManageUsers = styled("a")`
margin-top: 20px;
margin-top: 12px;
display: inline-flex;
&&&& {
text-decoration: none;

View File

@ -125,7 +125,7 @@ const UserRole = styled.div`
flex-basis: 25%;
flex-shrink: 0;
.${Classes.TEXT} {
color: ${(props) => props.theme.colors.modal.headerText};
color: ${Colors.COD_GRAY};
}
`;
@ -139,6 +139,14 @@ const UserName = styled.div`
&:nth-child(1) {
margin-bottom: 1px;
}
&[type="h5"] {
color: ${Colors.COD_GRAY};
}
&[type="p2"] {
color: ${Colors.GRAY};
}
}
`;
@ -155,8 +163,8 @@ const Loading = styled(Spinner)`
const MailConfigContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${(props) => props.theme.spaces[9]}px
${(props) => props.theme.spaces[2]}px;
padding: 24px 4px;
padding-bottom: 0;
align-items: center;
&& > span {
color: ${(props) => props.theme.colors.modal.email.message};

View File

@ -4,8 +4,8 @@ import {
ReduxActionTypes,
ReduxAction,
ReduxActionErrorTypes,
} from "@appsmith/constants/ReduxActionConstants";
import { set, keyBy } from "lodash";
} from "ce/constants/ReduxActionConstants";
import { set, keyBy, findIndex, unset } from "lodash";
import produce from "immer";
const initialState: JSCollectionDataState = [];
@ -14,6 +14,7 @@ export interface JSCollectionData {
config: JSCollection;
data?: Record<string, unknown>;
isExecuting?: Record<string, boolean>;
activeJSActionId?: string;
}
export type JSCollectionDataState = JSCollectionData[];
export interface PartialActionData {
@ -61,13 +62,25 @@ const jsActionsReducer = createReducer(initialState, {
),
[ReduxActionTypes.UPDATE_JS_ACTION_SUCCESS]: (
state: JSCollectionDataState,
action: ReduxAction<{ data: JSCollection }>,
action: ReduxAction<{
data: JSCollection;
}>,
): JSCollectionDataState =>
state.map((a) => {
if (a.config.id === action.payload.data.id) {
return { ...a, isLoading: false, config: action.payload.data };
state.map((jsCollection) => {
if (jsCollection.config.id === action.payload.data.id) {
return {
...jsCollection,
isLoading: false,
config: action.payload.data,
activeJSActionId:
findIndex(jsCollection.config.actions, {
id: jsCollection.activeJSActionId,
}) === -1
? undefined
: jsCollection.activeJSActionId,
};
}
return a;
return jsCollection;
}),
[ReduxActionTypes.UPDATE_JS_ACTION_BODY_SUCCESS]: (
state: JSCollectionDataState,
@ -254,12 +267,17 @@ const jsActionsReducer = createReducer(initialState, {
): JSCollectionDataState =>
state.map((a) => {
if (a.config.id === action.payload.collectionId) {
const newData = { ...a.data };
unset(newData, action.payload.action.id);
return {
...a,
isExecuting: {
...a.isExecuting,
[action.payload.action.id]: true,
},
data: {
...newData,
},
};
}
return a;
@ -353,6 +371,22 @@ const jsActionsReducer = createReducer(initialState, {
});
});
},
[ReduxActionTypes.SET_ACTIVE_JS_ACTION]: (
state: JSCollectionDataState,
action: ReduxAction<{
jsCollectionId: string;
jsActionId: string;
}>,
): JSCollectionDataState =>
state.map((jsCollection) => {
if (jsCollection.config.id === action.payload.jsCollectionId) {
return {
...jsCollection,
activeJSActionId: action.payload.jsActionId,
};
}
return jsCollection;
}),
});
export default jsActionsReducer;

View File

@ -105,6 +105,8 @@ import { submitCurlImportForm } from "actions/importActions";
import { getBasePath } from "pages/Editor/Explorer/helpers";
import { isTrueObject } from "workers/evaluationUtils";
import { handleExecuteJSFunctionSaga } from "sagas/JSPaneSagas";
import { Plugin } from "api/PluginApi";
enum ActionResponseDataTypes {
BINARY = "BINARY",
}
@ -835,17 +837,24 @@ function* executePluginActionSaga(
response: payload,
}),
);
let plugin;
let plugin: Plugin | undefined;
if (!!pluginAction.pluginId) {
plugin = yield select(getPlugin, pluginAction.pluginId);
}
yield put(
setActionResponseDisplayFormat({
id: actionId,
field: "responseDisplayFormat",
value: plugin && plugin.responseType ? plugin.responseType : "JSON",
}),
);
if (!!plugin) {
const responseType = payload?.dataTypes.find(
(type) =>
plugin?.responseType && type.dataType === plugin?.responseType,
);
yield put(
setActionResponseDisplayFormat({
id: actionId,
field: "responseDisplayFormat",
value: responseType ? responseType?.dataType : "JSON",
}),
);
}
return {
payload,
isError: isErrorResponse(response),

View File

@ -54,6 +54,7 @@ import {
JS_EXECUTION_SUCCESS,
JS_EXECUTION_FAILURE,
JS_EXECUTION_FAILURE_TOASTER,
JS_EXECUTION_SUCCESS_TOASTER,
JS_FUNCTION_CREATE_SUCCESS,
JS_FUNCTION_DELETE_SUCCESS,
JS_FUNCTION_UPDATE_SUCCESS,
@ -257,7 +258,12 @@ function* updateJSCollection(data: {
createMessage(JS_FUNCTION_DELETE_SUCCESS),
);
}
yield put(updateJSCollectionSuccess({ data: response?.data }));
yield put(
updateJSCollectionSuccess({
data: response?.data,
}),
);
}
}
} catch (error) {
@ -330,6 +336,10 @@ export function* handleExecuteJSFunctionSaga(data: {
},
state: { response: result },
});
Toaster.show({
text: createMessage(JS_EXECUTION_SUCCESS_TOASTER, action.name),
variant: Variant.success,
});
} catch (e) {
AppsmithConsole.addError({
id: actionId,

View File

@ -976,6 +976,7 @@ export function* generateTemplatePageSaga(
responseMeta: response.responseMeta,
},
pageId,
isFirstLoad: true,
});
// TODO : Add this to onSuccess (Redux Action)

View File

@ -12,18 +12,20 @@ import {
isEmbeddedRestDatasource,
} from "entities/Datasource";
import { Action, PluginType } from "entities/Action";
import { find, sortBy } from "lodash";
import { find, get, sortBy } from "lodash";
import ImageAlt from "assets/images/placeholder-image.svg";
import { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer";
import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants";
import { AppStoreState } from "reducers/entityReducers/appReducer";
import { JSCollectionDataState } from "reducers/entityReducers/jsActionsReducer";
import { JSCollection } from "entities/JSCollection";
import { DefaultPlugin, GenerateCRUDEnabledPluginMap } from "api/PluginApi";
import { JSAction, JSCollection } from "entities/JSCollection";
import { APP_MODE } from "entities/App";
import { ExplorerFileEntity } from "pages/Editor/Explorer/helpers";
import { ActionValidationConfigMap } from "constants/PropertyControlConstants";
import { selectFeatureFlags } from "./usersSelectors";
import { EvaluationError, EVAL_ERROR_PATH } from "utils/DynamicBindingUtils";
import { Severity } from "entities/AppsmithConsole";
export const getEntities = (state: AppState): AppState["entities"] =>
state.entities;
@ -782,3 +784,52 @@ function getActionValidationConfigFromPlugin(
}
return newValidationConfig;
}
export const getJSActions = (
state: AppState,
JSCollectionId: string,
): JSAction[] => {
const jsCollection = state.entities.jsActions.find(
(jsCollectionData) => jsCollectionData.config.id === JSCollectionId,
);
return jsCollection?.config.actions ?? [];
};
export const getActiveJSActionId = (
state: AppState,
jsCollectionId: string,
): string | null => {
const jsCollection = state.entities.jsActions.find(
(jsCollectionData) => jsCollectionData.config.id === jsCollectionId,
);
return jsCollection?.activeJSActionId ?? null;
};
export const getIsExecutingJSAction = (
state: AppState,
jsCollectionId: string,
actionId: string,
): boolean => {
const jsCollection = state.entities.jsActions.find(
(jsCollectionData) => jsCollectionData.config.id === jsCollectionId,
);
if (jsCollection?.isExecuting && jsCollection.isExecuting[actionId]) {
return jsCollection.isExecuting[actionId];
}
return false;
};
export const getJSCollectionParseErrors = (
state: AppState,
jsCollectionName: string,
) => {
const dataTree = state.evaluations.tree;
const allErrors = get(
dataTree,
`${jsCollectionName}.${EVAL_ERROR_PATH}.body`,
[],
) as EvaluationError[];
return allErrors.filter((error) => {
return error.severity === Severity.ERROR;
});
};

View File

@ -199,6 +199,13 @@ export type EventName =
| "GS_REGENERATE_SSH_KEY_CONFIRM_CLICK"
| "GS_REGENERATE_SSH_KEY_MORE_CLICK"
| "GS_SWITCH_BRANCH"
| "ADMIN_SETTINGS_RESET"
| "ADMIN_SETTINGS_SAVE"
| "ADMIN_SETTINGS_ERROR"
| "ADMIN_SETTINGS_DISCONNECT_AUTH_METHOD"
| "ADMIN_SETTINGS_UPGRADE_AUTH_METHOD"
| "ADMIN_SETTINGS_EDIT_AUTH_METHOD"
| "ADMIN_SETTINGS_ENABLE_AUTH_METHOD"
| "REFLOW_BETA_FLAG"
| "CONTAINER_JUMP"
| "CONNECT_GIT_CLICK"
@ -216,7 +223,8 @@ export type EventName =
| "RECONNECTING_SKIP_TO_APPLICATION_BUTTON_CLICK"
| "TEMPLATE_FILTER_SELECTED"
| "MANUAL_UPGRADE_CLICK"
| "PAGE_NOT_FOUND";
| "PAGE_NOT_FOUND"
| "RUN_JS_FUNCTION";
function getApplicationId(location: Location) {
const pathSplit = location.pathname.split("/");

View File

@ -1,4 +1,5 @@
import { useEffect } from "react";
import ResizeObserver from "resize-observer-polyfill";
const useResizeObserver = (
ref: HTMLDivElement | null,

View File

@ -1,6 +1,7 @@
import equal from "fast-deep-equal/es6";
import { difference, isEmpty } from "lodash";
import log from "loglevel";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { isDynamicValue } from "utils/DynamicBindingUtils";
import { MetaInternalFieldState } from ".";
@ -264,6 +265,17 @@ export const computeSchema = ({
const count = countFields(currSourceData);
if (count > MAX_ALLOWED_FIELDS) {
AnalyticsUtil.logEvent("WIDGET_PROPERTY_UPDATE", {
widgetType: "JSON_FORM_WIDGET",
widgetName,
propertyName: "sourceData",
updatedValue: currSourceData,
metaInfo: {
limitExceeded: true,
currentLimit: MAX_ALLOWED_FIELDS,
},
});
return {
status: ComputedSchemaStatus.LIMIT_EXCEEDED,
schema: prevSchema,

View File

@ -165,6 +165,11 @@ function TableHeader(props: TableHeaderProps) {
{props.isVisiblePagination && props.serverSidePaginationEnabled && (
<PaginationWrapper>
{props.totalRecordsCount ? (
<RowWrapper className="show-page-items">
{props.totalRecordsCount} Records
</RowWrapper>
) : null}
<PaginationItemWrapper
className="t--table-widget-prev-page"
disabled={props.pageNo === 0}
@ -174,9 +179,21 @@ function TableHeader(props: TableHeaderProps) {
>
<Icon color={Colors.HIT_GRAY} icon="chevron-left" iconSize={16} />
</PaginationItemWrapper>
<PaginationItemWrapper className="page-item" selected>
{props.pageNo + 1}
</PaginationItemWrapper>
{props.totalRecordsCount ? (
<RowWrapper>
Page&nbsp;
<PaginationItemWrapper className="page-item" selected>
{props.pageNo + 1}
</PaginationItemWrapper>
&nbsp;
<span>{`of ${props.pageCount}`}</span>
</RowWrapper>
) : (
<PaginationItemWrapper className="page-item" selected>
{props.pageNo + 1}
</PaginationItemWrapper>
)}
<PaginationItemWrapper
className="t--table-widget-next-page"
disabled={
@ -207,14 +224,14 @@ function TableHeader(props: TableHeaderProps) {
<Icon color={Colors.GRAY} icon="chevron-left" iconSize={16} />
</PaginationItemWrapper>
<RowWrapper>
Page{" "}
Page&nbsp;
<PageNumberInput
disabled={props.pageCount === 1}
pageCount={props.pageCount}
pageNo={props.pageNo + 1}
updatePageNo={props.updatePageNo}
/>{" "}
of {props.pageCount}
/>
&nbsp; of {props.pageCount}
</RowWrapper>
<PaginationItemWrapper
className="t--table-widget-next-page"

View File

@ -435,8 +435,12 @@ export const CellWrapper = styled.div<{
`;
export const CellCheckboxWrapper = styled(CellWrapper)<{ isChecked?: boolean }>`
justify-content: center;
width: 40px;
&&& {
justify-content: center;
width: 40px;
padding: 0px;
align-items: center;
}
& > div {
${(props) =>
props.isChecked

View File

@ -484,7 +484,6 @@ export const renderCheckBoxHeaderCell = (
isChecked={!!checkState}
onClick={onClick}
role="columnheader"
style={{ padding: "0px", justifyContent: "center" }}
>
<CellCheckbox>
{checkState === 1 && <CheckBoxCheckIcon className="th-svg" />}

View File

@ -1,7 +1,7 @@
import { parse, Node } from "acorn";
import { ancestor } from "acorn-walk";
import { ECMA_VERSION, NodeTypes } from "constants/ast";
import _ from "lodash";
import { ECMA_VERSION } from "workers/constants";
import { sanitizeScript } from "./evaluate";
/*
@ -23,19 +23,6 @@ import { sanitizeScript } from "./evaluate";
*
*/
// Each node has an attached type property which further defines
// what all properties can the node have.
// We will just define the ones we are working with
enum NodeTypes {
MemberExpression = "MemberExpression",
Identifier = "Identifier",
VariableDeclarator = "VariableDeclarator",
FunctionDeclaration = "FunctionDeclaration",
FunctionExpression = "FunctionExpression",
AssignmentPattern = "AssignmentPattern",
Literal = "Literal",
}
type Pattern = IdentifierNode | AssignmentPatternNode;
// doc: https://github.com/estree/estree/blob/master/es5.md#memberexpression
@ -88,8 +75,16 @@ interface LiteralNode extends Node {
value: string | boolean | null | number | RegExp;
}
// https://github.com/estree/estree/blob/master/es5.md#property
export interface PropertyNode extends Node {
type: NodeTypes.Property;
key: LiteralNode | IdentifierNode;
value: Node;
kind: "init" | "get" | "set";
}
/* We need these functions to typescript casts the nodes with the correct types */
const isIdentifierNode = (node: Node): node is IdentifierNode => {
export const isIdentifierNode = (node: Node): node is IdentifierNode => {
return node.type === NodeTypes.Identifier;
};
@ -113,10 +108,14 @@ const isAssignmentPatternNode = (node: Node): node is AssignmentPatternNode => {
return node.type === NodeTypes.AssignmentPattern;
};
const isLiteralNode = (node: Node): node is LiteralNode => {
export const isLiteralNode = (node: Node): node is LiteralNode => {
return node.type === NodeTypes.Literal;
};
export const isPropertyNode = (node: Node): node is PropertyNode => {
return node.type === NodeTypes.Property;
};
const isArrayAccessorNode = (node: Node): node is MemberExpressionNode => {
return (
isMemberExpressionNode(node) &&

View File

@ -1 +0,0 @@
export const ECMA_VERSION = 11;

View File

@ -11,8 +11,8 @@ import {
EvaluationScriptType,
ScriptTemplate,
} from "workers/evaluate";
import { ECMA_VERSION } from "workers/constants";
import { getLintSeverity } from "components/editorComponents/CodeEditor/lintHelpers";
import { ECMA_VERSION } from "constants/ast";
export const getPositionInEvaluationScript = (
type: EvaluationScriptType,

View File

@ -73,4 +73,10 @@ public enum ConditionalOperator {
return "or";
}
},
CONTAINS {
@Override
public String toString() {
return "like";
}
}
}

View File

@ -310,27 +310,25 @@ public class DataTypeStringUtils {
private static boolean isDisplayTypeTable(Object data) {
if (data instanceof List) {
// Check if the data is a list of simple json objects i.e. all values in the key value pairs are simple
// objects or their wrappers.
return ((List)data).stream()
.allMatch(item -> item instanceof Map
&& ((Map)item).entrySet().stream()
.allMatch(e -> ((Map.Entry)e).getValue() == null ||
isPrimitiveOrWrapper(((Map.Entry)e).getValue().getClass())));
// Check if the data is a list of json objects
return ((List) data).stream()
.allMatch(item -> item instanceof Map);
}
else if (data instanceof JsonNode) {
// Check if the data is an array of simple json objects
// Check if the data is an array of json objects
try {
objectMapper.convertValue(data, new TypeReference<List<Map<String, String>>>() {});
objectMapper.convertValue(data, new TypeReference<List<Map<String, Object>>>() {
});
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
else if (data instanceof String) {
// Check if the data is an array of simple json objects
// Check if the data is an array of json objects
try {
objectMapper.readValue((String)data, new TypeReference<List<Map<String, String>>>() {});
objectMapper.readValue((String) data, new TypeReference<List<Map<String, Object>>>() {
});
return true;
} catch (IOException e) {
return false;

View File

@ -75,6 +75,7 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
ConditionalOperator.NOT_EQ, "<>",
ConditionalOperator.GT, ">",
ConditionalOperator.GTE, ">=",
ConditionalOperator.CONTAINS, "LIKE",
ConditionalOperator.IN, "IN",
ConditionalOperator.NOT_IN, "NOT IN"
);
@ -99,6 +100,7 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
* Overloaded Method to handle plugin-based DataType conversion.
* Plugins implementing this passes parameter 'dataTypeConversionMap' to instruct how their DataTypes to be processed.
* Example: GoogleSheet plugin handled it in a way that Integer, Long and Float DataTypes to be treated as Double.
*
* @param items
* @param conditionList
* @param dataTypeConversionMap - A Map to provide custom Datatype(value) against the actual Datatype(key) found.
@ -137,7 +139,8 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
/**
* This filter method is using the new UQI format.
* @param items - data
*
* @param items - data
* @param uqiDataFilterParams - filter conditions to apply on data
* @return filtered data
*/
@ -292,9 +295,9 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
* E.g. if the projectionColumns is a list that contains ["ID, Name"], then this method will add the following
* SQL line: `SELECT ID, Name from tableName`, otherwise it will add: `SELECT * FROM tableName`
*
* @param sb - SQL query builder
* @param sb - SQL query builder
* @param projectionColumns - list of columns that need to be displayed
* @param tableName - table name in database
* @param tableName - table name in database
*/
private void addProjectionCondition(StringBuilder sb, List<String> projectionColumns, String tableName) {
if (!CollectionUtils.isEmpty(projectionColumns)) {
@ -304,8 +307,7 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
sb.setLength(sb.length() - 1);
sb.append(" FROM " + tableName);
}
else {
} else {
sb.append("SELECT * FROM " + tableName);
}
}
@ -313,12 +315,12 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
/**
* This method adds `ORDER BY` clause to the SQL query. E.g. if the sortBy list is
* [
* {"columnName": "ID", "type": "ASCENDING"},
* {"columnName": "Name", "type": "DESCENDING"}
* {"columnName": "ID", "type": "ASCENDING"},
* {"columnName": "Name", "type": "DESCENDING"}
* ]
* then this method will add the following line to the SQL query: `ORDER BY ID ASC, Name DESC`
*
* @param sb - SQL query builder
* @param sb - SQL query builder
* @param sortBy - list of columns to sort by and sort type (ascending / descending)
* @throws AppsmithPluginException
*/
@ -354,8 +356,8 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
/**
* Checks if:
* o `sortBy` condition list is null or empty
* o all column names in the sortBy list are empty
* o `sortBy` condition list is null or empty
* o all column names in the sortBy list are empty
*/
private boolean isSortConditionEmpty(List<Map<String, String>> sortBy) {
if (CollectionUtils.isEmpty(sortBy)) {
@ -368,9 +370,10 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
/**
* Filter Query before UQI implementation
* @param tableName - table name in database
* @param conditions - Where Conditions
* @param schema - The Schema
*
* @param tableName - table name in database
* @param conditions - Where Conditions
* @param schema - The Schema
* @param dataTypeConversionMap - A Map to provide custom Datatype against the actual Datatype found.
* @return
*/
@ -459,9 +462,20 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
sb.append(" ");
if (value == null || value.equals(StringUtils.EMPTY)) {
if (operator == ConditionalOperator.EQ || operator == ConditionalOperator.IN) {
if (Set.of(
ConditionalOperator.EQ,
ConditionalOperator.IN,
ConditionalOperator.CONTAINS,
ConditionalOperator.LTE,
ConditionalOperator.LT
).contains(operator)) {
sb.append("IS NULL");
} else if (operator == ConditionalOperator.NOT_IN) {
} else if (Set.of(
ConditionalOperator.NOT_IN,
ConditionalOperator.NOT_EQ,
ConditionalOperator.GTE,
ConditionalOperator.GT
).contains(operator)) {
sb.append("IS NOT NULL");
}
isEmptyConditionValue = true;
@ -470,50 +484,62 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
}
sb.append(" ");
// These are array operations. Convert value into appropriate format and then append
if (!(value == null || StringUtils.EMPTY.equals(value)) && //value should not be EMPTY or null
(operator == ConditionalOperator.IN || operator == ConditionalOperator.NOT_IN)) {
// value should not be EMPTY or null
if (!isEmptyConditionValue) {
// These are array operations. Convert value into appropriate format and then append
if (operator == ConditionalOperator.IN || operator == ConditionalOperator.NOT_IN) {
StringBuilder valueBuilder = new StringBuilder("(");
StringBuilder valueBuilder = new StringBuilder("(");
try {
List<Object> arrayValues = objectMapper.readValue(value, List.class);
List<String> updatedStringValues = arrayValues
.stream()
.map(fieldValue -> {
values.add(new PreparedStatementValueDTO(String.valueOf(fieldValue), schema.get(path)));
return "?";
})
.collect(Collectors.toList());
String finalValues = String.join(",", updatedStringValues);
valueBuilder.append(finalValues);
} catch (IOException e) {
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR,
value + " could not be parsed into an array");
try {
List<Object> arrayValues = objectMapper.readValue(value, List.class);
List<String> updatedStringValues = arrayValues
.stream()
.map(fieldValue -> {
values.add(new PreparedStatementValueDTO(String.valueOf(fieldValue), schema.get(path)));
return "?";
})
.collect(Collectors.toList());
String finalValues = String.join(",", updatedStringValues);
valueBuilder.append(finalValues);
} catch (IOException e) {
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR,
value + " could not be parsed into an array");
}
valueBuilder.append(")");
value = valueBuilder.toString();
sb.append(value);
} else if (operator == ConditionalOperator.CONTAINS) {
String escapedLikeValue = value
.replace("!", "!!")
.replace("%", "!%")
.replace("_", "!_")
.replace("[", "![");
sb.append("? ESCAPE '!'");
escapedLikeValue = "%" + escapedLikeValue + "%";
values.add(new PreparedStatementValueDTO(escapedLikeValue, schema.get(path)));
} else {
// Not an array. Simply add a placeholder
sb.append("?");
values.add(new PreparedStatementValueDTO(value, schema.get(path)));
}
valueBuilder.append(")");
value = valueBuilder.toString();
sb.append(value);
} else if (!isEmptyConditionValue) {
// Not an array. Simply add a placeholder
sb.append("?");
values.add(new PreparedStatementValueDTO(value, schema.get(path)));
}
}
return sb.toString();
}
public void insertAllData(String tableName, ArrayNode items, Map<String, DataType> schema) {
insertAllData( tableName, items, schema, null);
insertAllData(tableName, items, schema, null);
}
/**
* Overloaded Method to handle plugin-based DataType conversion.
* @param tableName - table name in database
* @param items - Data
* @param schema - The Schema
*
* @param tableName - table name in database
* @param items - Data
* @param schema - The Schema
* @param dataTypeConversionMap - A Map to provide custom Datatype against the actual Datatype found.
*/
public void insertAllData(String tableName, ArrayNode items, Map<String, DataType> schema, Map<DataType, DataType> dataTypeConversionMap) {
@ -714,6 +740,7 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
/**
* Overloaded Method to handle plugin-based DataType conversion.
*
* @param items
* @param dataTypeConversionMap - A Map to provide custom Datatype against the actual Datatype found.
* @return
@ -761,7 +788,7 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
} else {
DataType foundDataType = stringToKnownDataTypeConverter(value);
DataType convertedDataType = foundDataType;
if(name != "rowIndex" && dataTypeConversionMap != null) {
if (name != "rowIndex" && dataTypeConversionMap != null) {
convertedDataType = dataTypeConversionMap.getOrDefault(foundDataType, foundDataType);
}
return convertedDataType;
@ -786,7 +813,7 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
if (!StringUtils.isEmpty(value)) {
DataType foundDataType = stringToKnownDataTypeConverter(value);
DataType dataType = foundDataType;
if(dataTypeConversionMap != null) {
if (dataTypeConversionMap != null) {
dataType = dataTypeConversionMap.getOrDefault(foundDataType, foundDataType);
}
schema.put(columnName, dataType);
@ -810,6 +837,7 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
/**
* Overloaded Method to handle plugin-based DataType conversion.
*
* @param preparedStatement
* @param index
* @param value
@ -825,7 +853,7 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
dataType = dataTypeConversionMap.getOrDefault(topRowDataType, topRowDataType);
}
String strNumericValue = value.trim().replaceAll(",","");
String strNumericValue = value.trim().replaceAll(",", "");
// Override datatype to null for empty values
if (StringUtils.isEmpty(value)) {
@ -985,6 +1013,14 @@ public class FilterDataServiceCE implements IFilterDataServiceCE {
value = valueBuilder.toString();
sb.append(value);
} else if (operator == ConditionalOperator.CONTAINS) {
final String escapedLikeValue = value
.replace("!", "!!")
.replace("%", "!%")
.replace("_", "!_")
.replace("[", "![");
sb.append("? ESCAPE '!'");
values.add(new PreparedStatementValueDTO("%" + escapedLikeValue + "%", schema.get(path)));
} else {
// Not an array. Simply add a placeholder
sb.append("?");

View File

@ -1,8 +1,19 @@
package com.appsmith.external.helpers;
import com.appsmith.external.constants.DataType;
import com.appsmith.external.constants.DisplayDataType;
import com.appsmith.external.models.ParsedDataType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.appsmith.external.helpers.DataTypeStringUtils.getDisplayDataTypes;
import static com.appsmith.external.helpers.DataTypeStringUtils.stringToKnownDataTypeConverter;
import static org.assertj.core.api.Assertions.assertThat;
@ -176,4 +187,49 @@ public class DataTypeStringUtilsTest {
assertThat(DataType.JSON_OBJECT).isEqualByComparingTo(stringToKnownDataTypeConverter("{\"a\": \"\"}"));
assertThat(DataType.JSON_OBJECT).isEqualByComparingTo(stringToKnownDataTypeConverter("{\"a\": []}"));
}
@Test
public void testGetDisplayDataTypes_withNestedObjectsInList_returnsWithTable() {
final List<Object> data = new ArrayList<>();
final Map<String, Object> objectMap = new HashMap<>();
final Map<String, Object> nestedObjectMap = new HashMap<>();
nestedObjectMap.put("k2", "v2");
objectMap.put("k", nestedObjectMap);
data.add(objectMap);
final List<ParsedDataType> displayDataTypes = getDisplayDataTypes(data);
assertThat(displayDataTypes).anyMatch(parsedDataType -> parsedDataType.getDataType().equals(DisplayDataType.TABLE));
}
@Test
public void testGetDisplayDataTypes_withNestedObjectsInArrayNode_returnsWithTable() {
final ObjectMapper objectMapper = new ObjectMapper();
final ArrayNode data = objectMapper.createArrayNode();
final ObjectNode objectNode = objectMapper.createObjectNode();
final ObjectNode nestedObjectNode = objectMapper.createObjectNode();
nestedObjectNode.put("k2", "v2");
objectNode.set("k", nestedObjectNode);
data.add(objectNode);
final List<ParsedDataType> displayDataTypes = getDisplayDataTypes(data);
assertThat(displayDataTypes).anyMatch(parsedDataType -> parsedDataType.getDataType().equals(DisplayDataType.TABLE));
}
@Test
public void testGetDisplayDataTypes_withNestedObjectsInString_returnsWithTable() {
final ObjectMapper objectMapper = new ObjectMapper();
final ArrayNode data = objectMapper.createArrayNode();
final ObjectNode objectNode = objectMapper.createObjectNode();
final ObjectNode nestedObjectNode = objectMapper.createObjectNode();
nestedObjectNode.put("k2", "v2");
objectNode.set("k", nestedObjectNode);
data.add(objectNode);
final List<ParsedDataType> displayDataTypes = getDisplayDataTypes(data.toString());
assertThat(displayDataTypes).anyMatch(parsedDataType -> parsedDataType.getDataType().equals(DisplayDataType.TABLE));
}
}

View File

@ -139,10 +139,15 @@ public class FilterDataServiceTest {
Condition condition1 = new Condition("anotherKey", "GT", "15");
conditionList.add(condition1);
Condition condition2 = new Condition("orderStatus", "EQ", "READY");
conditionList.add(condition2);
Condition condition3 = new Condition("productName", "CONTAINS", "Chicken");
conditionList.add(condition3);
Condition condition4 = new Condition("productName", "NOT_EQ", "Chicken Sub");
conditionList.add(condition4);
ArrayNode filteredData = filterDataService.filterData(items, conditionList);
assertEquals(filteredData.size(), 1);

View File

@ -402,6 +402,10 @@
"label": "==",
"value": "EQ"
},
{
"label": "!=",
"value": "NOT_EQ"
},
{
"label": ">=",
"value": "GTE"
@ -410,6 +414,10 @@
"label": ">",
"value": "GT"
},
{
"label": "contains",
"value": "CONTAINS"
},
{
"label": "in",
"value": "IN"

View File

@ -35,6 +35,10 @@ public class ApplicationJson {
List<NewPage> pageList;
List<String> pageOrder;
List<String> publishedPageOrder;
String publishedDefaultPageName;
String unpublishedDefaultPageName;

View File

@ -62,6 +62,7 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static com.appsmith.external.helpers.AppsmithBeanUtils.copyNestedNonNullProperties;
import static com.appsmith.server.acl.AclPermission.MANAGE_ACTIONS;
import static com.appsmith.server.acl.AclPermission.MANAGE_APPLICATIONS;
import static com.appsmith.server.acl.AclPermission.MANAGE_PAGES;
@ -557,7 +558,9 @@ public class ApplicationPageServiceCEImpl implements ApplicationPageServiceCE {
unpublishedCollection.setPageId(newPageId);
actionCollection.setApplicationId(clonedPage.getApplicationId());
actionCollection.setDefaultResources(clonedPageDefaultResources);
DefaultResources defaultResources = new DefaultResources();
copyNestedNonNullProperties(clonedPageDefaultResources, defaultResources);
actionCollection.setDefaultResources(defaultResources);
DefaultResources defaultResourcesForDTO = new DefaultResources();
defaultResourcesForDTO.setPageId(clonedPageDefaultResources.getPageId());
@ -576,14 +579,27 @@ public class ApplicationPageServiceCEImpl implements ApplicationPageServiceCE {
if (StringUtils.isEmpty(clonedPageDefaultResources.getBranchName())) {
unpublishedCollection
.getDefaultToBranchedActionIdsMap()
.forEach((defaultId, oldActionId) ->
updatedDefaultToBranchedActionId.put(actionIdsMap.get(oldActionId), actionIdsMap.get(oldActionId)));
.forEach((defaultId, oldActionId) -> {
// Filter out the actionIds for which the reference is not
// present in cloned actions, this happens when we have
// deleted action in unpublished mode
if (StringUtils.hasLength(oldActionId) && StringUtils.hasLength(actionIdsMap.get(oldActionId))) {
updatedDefaultToBranchedActionId
.put(actionIdsMap.get(oldActionId), actionIdsMap.get(oldActionId));
}
});
} else {
unpublishedCollection
.getDefaultToBranchedActionIdsMap()
.forEach((defaultId, oldActionId) ->
updatedDefaultToBranchedActionId.put(defaultId, actionIdsMap.get(oldActionId)));
.forEach((defaultId, oldActionId) -> {
// Filter out the actionIds for which the reference is not
// present in cloned actions, this happens when we have
// deleted action in unpublished mode
if (StringUtils.hasLength(defaultId) && StringUtils.hasLength(actionIdsMap.get(oldActionId))) {
updatedDefaultToBranchedActionId
.put(defaultId, actionIdsMap.get(oldActionId));
}
});
}
unpublishedCollection.setDefaultToBranchedActionIdsMap(updatedDefaultToBranchedActionId);

View File

@ -99,4 +99,5 @@ public interface NewActionServiceCE extends CrudService<NewAction, String> {
Mono<String> findBranchedIdByBranchNameAndDefaultActionId(String branchName, String defaultActionId, AclPermission permission);
public Mono<NewAction> sanitizeAction(NewAction action);
}

View File

@ -120,6 +120,8 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
public static final String NATIVE_QUERY_PATH = "formToNativeQuery";
public static final String NATIVE_QUERY_PATH_DATA = NATIVE_QUERY_PATH + "." + DATA;
public static final String NATIVE_QUERY_PATH_STATUS = NATIVE_QUERY_PATH + "." + STATUS;
public static final PluginType JS_PLUGIN_TYPE = PluginType.JS;
public static final String JS_PLUGIN_PACKAGE_NAME = "js-plugin";
private final NewActionRepository repository;
private final DatasourceService datasourceService;
@ -230,6 +232,8 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
} else {
if (newAction.getUnpublishedAction() != null) {
action = newAction.getUnpublishedAction();
} else {
return Mono.error(new AppsmithException(AppsmithError.INVALID_ACTION, newAction.getId(), "No unpublished action found for edit mode"));
}
}
@ -248,6 +252,9 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
@Override
public void generateAndSetActionPolicies(NewPage page, NewAction action) {
if (page == null) {
throw new AppsmithException(AppsmithError.INTERNAL_SERVER_ERROR, "No page found to copy policies from.");
}
Set<Policy> documentPolicies = policyGenerator.getAllChildPolicies(page.getPolicies(), Page.class, Action.class);
action.setPolicies(documentPolicies);
}
@ -371,10 +378,10 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
action.setInvalids(invalids);
action.setPluginName(plugin.getName());
newAction.setUnpublishedAction(action);
newAction.setPluginType(plugin.getType());
newAction.setPluginId(plugin.getId());
return newAction;
}).map(this::extractAndSetJsonPathKeys)
})
.flatMap(this::sanitizeAction)
.map(this::extractAndSetJsonPathKeys)
.map(updatedAction -> {
// In case of external datasource (not embedded) instead of storing the entire datasource
// again inside the action, instead replace it with just the datasource ID. This is so that
@ -1100,7 +1107,8 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
@Override
public Flux<NewAction> findUnpublishedOnLoadActionsExplicitSetByUserInPage(String pageId) {
return repository
.findUnpublishedActionsByPageIdAndExecuteOnLoadSetByUserTrue(pageId, MANAGE_ACTIONS);
.findUnpublishedActionsByPageIdAndExecuteOnLoadSetByUserTrue(pageId, MANAGE_ACTIONS)
.flatMap(this::sanitizeAction);
}
/**
@ -1113,27 +1121,32 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
@Override
public Flux<NewAction> findUnpublishedActionsInPageByNames(Set<String> names, String pageId) {
return repository
.findUnpublishedActionsByNameInAndPageId(names, pageId, MANAGE_ACTIONS);
.findUnpublishedActionsByNameInAndPageId(names, pageId, MANAGE_ACTIONS)
.flatMap(this::sanitizeAction);
}
@Override
public Mono<NewAction> findById(String id) {
return repository.findById(id);
return repository.findById(id)
.flatMap(this::sanitizeAction);
}
@Override
public Mono<NewAction> findById(String id, AclPermission aclPermission) {
return repository.findById(id, aclPermission);
return repository.findById(id, aclPermission)
.flatMap(this::sanitizeAction);
}
@Override
public Flux<NewAction> findByPageId(String pageId, AclPermission permission) {
return repository.findByPageId(pageId, permission);
return repository.findByPageId(pageId, permission)
.flatMap(this::sanitizeAction);
}
@Override
public Flux<NewAction> findByPageIdAndViewMode(String pageId, Boolean viewMode, AclPermission permission) {
return repository.findByPageIdAndViewMode(pageId, viewMode, permission);
return repository.findByPageIdAndViewMode(pageId, viewMode, permission)
.flatMap(this::sanitizeAction);
}
@Override
@ -1151,7 +1164,8 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
// every created action starts from an unpublishedAction state.
return Mono.just(action);
});
})
.flatMap(this::sanitizeAction);
}
@Override
@ -1320,9 +1334,11 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
return applicationService
.findById(params.getFirst(FieldName.APPLICATION_ID), READ_APPLICATIONS)
.flatMapMany(application -> repository.findByApplicationIdAndViewMode(application.getId(), false, READ_ACTIONS))
.flatMap(this::sanitizeAction)
.flatMap(this::setTransientFieldsInUnpublishedAction);
}
return repository.findAllActionsByNameAndPageIdsAndViewMode(name, pageIds, false, READ_ACTIONS, sort)
.flatMap(this::sanitizeAction)
.flatMap(this::setTransientFieldsInUnpublishedAction);
}
@ -1365,6 +1381,75 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
.filter(actionDTO -> !PluginType.JS.equals(actionDTO.getPluginType()));
}
/**
* This method is meant to be used to check for any missing or bad values in NewAction object and attempt to fix it.
*
* This method is added in response to certain cases where it was found that pluginId and pluginType keys
* were missing from the NewAction object in the database.Since it is currently not know what exactly causes
* these values to go missing, this check will serve as a workaround by fetching and setting pluginId and
* pluginType using the datasource object contained in the ActionDTO object.
* Ref: https://github.com/appsmithorg/appsmith/issues/11927
*
*/
public Mono<NewAction> sanitizeAction(NewAction action) {
Mono<NewAction> actionMono = Mono.just(action);
if (isPluginTypeOrPluginIdMissing(action)) {
actionMono = providePluginTypeAndIdToNewActionObjectUsingJSTypeOrDatasource(action);
}
return actionMono;
}
private boolean isPluginTypeOrPluginIdMissing(NewAction action) {
return action.getPluginId() == null || action.getPluginType() == null;
}
private Mono<NewAction> providePluginTypeAndIdToNewActionObjectUsingJSTypeOrDatasource(NewAction action) {
ActionDTO actionDTO = action.getUnpublishedAction();
if (actionDTO == null) {
return Mono.just(action);
}
/**
* if path:
* In case an action object is related to a JS Object then it must have a non-null collectionId.
*
* else path:
* Otherwise, check if the datasource object has the pluginId. If so, use this pluginId to fetch the correct
* pluginType.
*/
Datasource datasource = actionDTO.getDatasource();
if (actionDTO.getCollectionId() != null) {
return setPluginIdAndTypeForJSAction(action);
}
else if (datasource != null && datasource.getPluginId() != null) {
String pluginId = datasource.getPluginId();
action.setPluginId(pluginId);
return setPluginTypeFromId(action, pluginId);
}
return Mono.just(action);
}
private Mono<NewAction> setPluginTypeFromId(NewAction action, String pluginId) {
return pluginService.findById(pluginId)
.flatMap(plugin -> {
action.setPluginType(plugin.getType());
return Mono.just(action);
});
}
private Mono<NewAction> setPluginIdAndTypeForJSAction(NewAction action) {
action.setPluginType(JS_PLUGIN_TYPE);
return pluginService.findByPackageName(JS_PLUGIN_PACKAGE_NAME)
.flatMap(plugin -> {
action.setPluginId(plugin.getId());
return Mono.just(action);
});
}
// We can afford to make this call all the time since we already have all the info we need in context
private Mono<DatasourceContext> getRemoteDatasourceContext(Plugin plugin, Datasource datasource) {
final DatasourceContext datasourceContext = new DatasourceContext();
@ -1388,7 +1473,9 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
if (action.getGitSyncId() == null) {
action.setGitSyncId(action.getApplicationId() + "_" + Instant.now().toString());
}
return repository.save(action);
return sanitizeAction(action)
.flatMap(sanitizedAction -> repository.save(sanitizedAction));
}
@Override
@ -1396,12 +1483,17 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
actions.stream()
.filter(action -> action.getGitSyncId() == null)
.forEach(action -> action.setGitSyncId(action.getApplicationId() + "_" + Instant.now().toString()));
return repository.saveAll(actions);
return Flux.fromIterable(actions)
.flatMap(this::sanitizeAction)
.collectList()
.flatMapMany(actionList -> repository.saveAll(actionList));
}
@Override
public Flux<NewAction> findByPageId(String pageId) {
return repository.findByPageId(pageId);
return repository.findByPageId(pageId)
.flatMap(this::sanitizeAction);
}
/**
@ -1642,7 +1734,8 @@ public class NewActionServiceCEImpl extends BaseService<NewActionRepository, New
return repository.findByBranchNameAndDefaultActionId(branchName, defaultActionId, permission)
.switchIfEmpty(Mono.error(
new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.ACTION, defaultActionId + "," + branchName))
);
)
.flatMap(this::sanitizeAction);
}
public Mono<String> findBranchedIdByBranchNameAndDefaultActionId(String branchName, String defaultActionId, AclPermission permission) {

View File

@ -112,9 +112,15 @@ public class ApplicationFetcherCEImpl implements ApplicationFetcherCE {
// Collect all the applications as a map with organization id as a key
Flux<Application> applicationFlux = applicationRepository
.findByMultipleOrganizationIds(orgIds, READ_APPLICATIONS)
// Git connected apps will have gitApplicationMetadat
.filter(application -> application.getGitApplicationMetadata() == null
|| (!StringUtils.isEmpty(application.getGitApplicationMetadata().getDefaultBranchName())
&& application.getGitApplicationMetadata().getBranchName().equals(application.getGitApplicationMetadata().getDefaultBranchName()))
// 1. When the ssh key is generated by user and then the connect app fails
|| (StringUtils.isEmpty(application.getGitApplicationMetadata().getDefaultBranchName())
&& StringUtils.isEmpty(application.getGitApplicationMetadata().getBranchName()))
// 2. When the DefaultBranchName is missing due to branch creation flow failures or corrupted scenarios
|| (!StringUtils.isEmpty(application.getGitApplicationMetadata().getBranchName())
&& application.getGitApplicationMetadata().getBranchName().equals(application.getGitApplicationMetadata().getDefaultBranchName())
)
)
.map(responseUtils::updateApplicationWithDefaultResources);

View File

@ -314,11 +314,13 @@ public class ExamplesOrganizationClonerCEImpl implements ExamplesOrganizationClo
unpublishedCollection
.getDefaultToBranchedActionIdsMap()
.forEach((defaultActionId, oldActionId) -> {
if (!StringUtils.isEmpty(actionIdsMap.get(oldActionId))) {
if (StringUtils.hasLength(oldActionId)
&& StringUtils.hasLength(actionIdsMap.get(oldActionId))) {
// As this is a new application and not connected
// through git branch, the default and newly
// created actionId will be same
newActionIds
// As this is a new application and not connected
// through git branch, the default and newly
// created actionId will be same
.put(actionIdsMap.get(oldActionId), actionIdsMap.get(oldActionId));
} else {
log.debug("Unable to find action {} while forking inside ID map: {}", oldActionId, actionIdsMap);

View File

@ -77,6 +77,7 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@ -233,6 +234,7 @@ public class ImportExportApplicationServiceCEImpl implements ImportExportApplica
// Refactor application to remove the ids
final String organizationId = application.getOrganizationId();
List<String> pageOrderList = application.getPages().stream().map(applicationPage -> applicationPage.getId()).collect(Collectors.toList());
List<String> publishedPageOrderList = application.getPublishedPages().stream().map(applicationPage -> applicationPage.getId()).collect(Collectors.toList());
removeUnwantedFieldsFromApplicationDuringExport(application);
examplesOrganizationCloner.makePristine(application);
applicationJson.setExportedApplication(application);
@ -244,9 +246,24 @@ public class ImportExportApplicationServiceCEImpl implements ImportExportApplica
return pageFlux
.collectList()
// Maintain the page order while exporting the application
// Save the page order to json while exporting the application
.flatMap(newPages -> {
Collections.sort(newPages, Comparator.comparing(newPage -> pageOrderList.indexOf(newPage.getId())));
List<String> pageOrder = new ArrayList<>();
for (NewPage page: newPages) {
pageOrder.add(page.getUnpublishedPage().getName());
}
applicationJson.setPageOrder(pageOrder);
List<NewPage> newPageList = newPages;
// When there is difference in number of pages between edit and view mode
newPageList = newPageList.stream().filter(newPage -> !Optional.ofNullable(newPage.getPublishedPage()).isEmpty()).collect(Collectors.toList());
Collections.sort(newPageList, Comparator.comparing(newPage -> publishedPageOrderList.indexOf(newPage.getId())));
pageOrder = new ArrayList<>();
for (NewPage page: newPageList) {
pageOrder.add(page.getPublishedPage().getName());
}
applicationJson.setPublishedPageOrder(pageOrder);
return Mono.just(newPages);
})
.flatMap(newPageList -> {
@ -306,7 +323,7 @@ public class ImportExportApplicationServiceCEImpl implements ImportExportApplica
Flux<Datasource> datasourceFlux = Boolean.TRUE.equals(application.getExportWithConfiguration())
? datasourceRepository.findAllByOrganizationId(organizationId, AclPermission.READ_DATASOURCES)
: datasourceRepository.findAllByOrganizationId(organizationId, AclPermission.MANAGE_DATASOURCES);
: datasourceRepository.findAllByOrganizationId(organizationId, MANAGE_DATASOURCES);
return datasourceFlux.collectList();
})
@ -854,15 +871,17 @@ public class ImportExportApplicationServiceCEImpl implements ImportExportApplica
existingPagesMono
).collectList()
.map(newPageList -> {
// Check if the pages order match with json file
List<String> pageOrderList = importedNewPageList.stream().map(newPage -> newPage.getUnpublishedPage().getName()).collect(Collectors.toList());
// Sort the unPublished pages based on the json file order only
List<String> pageOrderList;
if(Optional.ofNullable(applicationJson.getPageOrder()).isEmpty()) {
pageOrderList = importedNewPageList.stream().map(newPage -> newPage.getUnpublishedPage().getName()).collect(Collectors.toList());
} else {
pageOrderList = applicationJson.getPageOrder();
}
Collections.sort(newPageList,
Comparator.comparing(newPage -> pageOrderList.indexOf(newPage.getUnpublishedPage().getName())));
for (NewPage newPage : newPageList) {
ApplicationPage unpublishedAppPage = new ApplicationPage();
ApplicationPage publishedAppPage = new ApplicationPage();
if (newPage.getUnpublishedPage() != null && newPage.getUnpublishedPage().getName() != null) {
unpublishedAppPage.setIsDefault(
StringUtils.equals(
@ -875,6 +894,27 @@ public class ImportExportApplicationServiceCEImpl implements ImportExportApplica
}
pageNameMap.put(newPage.getUnpublishedPage().getName(), newPage);
}
if (unpublishedAppPage.getId() != null && newPage.getUnpublishedPage().getDeletedAt() == null) {
applicationPages.get(PublishType.UNPUBLISHED).add(unpublishedAppPage);
}
}
// Sort the published pages based on the json file order only
List<String> publishedPageOrderList;
if(Optional.ofNullable(applicationJson.getPublishedPageOrder()).isEmpty()) {
publishedPageOrderList = importedNewPageList.stream()
.filter(newPage -> !Optional.ofNullable(newPage.getPublishedPage()).isEmpty())
.map(newPage -> newPage.getPublishedPage().getName()).collect(Collectors.toList());
} else {
publishedPageOrderList = applicationJson.getPublishedPageOrder();
}
// When there is difference in number of pages between edit and view mode
newPageList = newPageList.stream().filter(newPage -> !Optional.ofNullable(newPage.getPublishedPage()).isEmpty()).collect(Collectors.toList());
Collections.sort(newPageList,
Comparator.comparing(newPage -> publishedPageOrderList.indexOf(newPage.getPublishedPage().getName())));
for (NewPage newPage : newPageList) {
ApplicationPage publishedAppPage = new ApplicationPage();
if (newPage.getPublishedPage() != null && newPage.getPublishedPage().getName() != null) {
publishedAppPage.setIsDefault(
@ -888,9 +928,7 @@ public class ImportExportApplicationServiceCEImpl implements ImportExportApplica
}
pageNameMap.put(newPage.getPublishedPage().getName(), newPage);
}
if (unpublishedAppPage.getId() != null && newPage.getUnpublishedPage().getDeletedAt() == null) {
applicationPages.get(PublishType.UNPUBLISHED).add(unpublishedAppPage);
}
if (publishedAppPage.getId() != null && newPage.getPublishedPage().getDeletedAt() == null) {
applicationPages.get(PublishType.PUBLISHED).add(publishedAppPage);
}
@ -929,6 +967,7 @@ public class ImportExportApplicationServiceCEImpl implements ImportExportApplica
});
})
.flatMap(applicationPageMap -> {
// set it based on the order for published and unublished pages
importedApplication.setPages(applicationPageMap.get(PublishType.UNPUBLISHED));
importedApplication.setPublishedPages(applicationPageMap.get(PublishType.PUBLISHED));

View File

@ -4,7 +4,7 @@
"displayName": "Classic",
"config": {
"colors": {
"primaryColor": "#50AF6C",
"primaryColor": "#16a34a",
"backgroundColor": "#F6F6F6"
},
"borderRadius": {
@ -50,7 +50,6 @@
},
"BUTTON_GROUP_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none",
"childStylesheet": {
"button": {
@ -100,7 +99,6 @@
"FILE_PICKER_WIDGET_V2": {
"buttonColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"FORM_WIDGET": {
@ -119,24 +117,22 @@
},
"IFRAME_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "{{appsmith.theme.boxShadow.appBoxShadow}}"
},
"IMAGE_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"INPUT_WIDGET": {
"accentColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"INPUT_WIDGET_V2": {
"accentColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"JSON_FORM_WIDGET": {
@ -338,6 +334,7 @@
"boxShadow": "{{appsmith.theme.boxShadow.appBoxShadow}}"
},
"TEXT_WIDGET": {
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}"
},
"VIDEO_WIDGET": {
@ -352,7 +349,7 @@
},
"properties": {
"colors": {
"primaryColor": "#50AF6C",
"primaryColor": "#16a34a",
"backgroundColor": "#F6F6F6"
},
"borderRadius": {
@ -417,7 +414,7 @@
},
"BUTTON_GROUP_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none",
"childStylesheet": {
"button": {
@ -467,7 +464,7 @@
"FILE_PICKER_WIDGET_V2": {
"buttonColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"FORM_WIDGET": {
@ -486,24 +483,24 @@
},
"IFRAME_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "{{appsmith.theme.boxShadow.appBoxShadow}}"
},
"IMAGE_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"INPUT_WIDGET": {
"accentColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"INPUT_WIDGET_V2": {
"accentColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"JSON_FORM_WIDGET": {
@ -705,6 +702,7 @@
"boxShadow": "{{appsmith.theme.boxShadow.appBoxShadow}}"
},
"TEXT_WIDGET": {
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}"
},
"VIDEO_WIDGET": {
@ -784,7 +782,7 @@
},
"BUTTON_GROUP_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none",
"childStylesheet": {
"button": {
@ -834,7 +832,7 @@
"FILE_PICKER_WIDGET_V2": {
"buttonColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"FORM_WIDGET": {
@ -853,24 +851,24 @@
},
"IFRAME_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "{{appsmith.theme.boxShadow.appBoxShadow}}"
},
"IMAGE_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"INPUT_WIDGET": {
"accentColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"INPUT_WIDGET_V2": {
"accentColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"JSON_FORM_WIDGET": {
@ -1072,7 +1070,8 @@
"boxShadow": "{{appsmith.theme.boxShadow.appBoxShadow}}"
},
"TEXT_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}"
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}"
},
"VIDEO_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
@ -1151,7 +1150,7 @@
},
"BUTTON_GROUP_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none",
"childStylesheet": {
"button": {
@ -1201,7 +1200,6 @@
"FILE_PICKER_WIDGET_V2": {
"buttonColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"FORM_WIDGET": {
@ -1220,24 +1218,20 @@
},
"IFRAME_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "{{appsmith.theme.boxShadow.appBoxShadow}}"
},
"IMAGE_WIDGET": {
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"INPUT_WIDGET": {
"accentColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"INPUT_WIDGET_V2": {
"accentColor": "{{appsmith.theme.colors.primaryColor}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"boxShadow": "none"
},
"JSON_FORM_WIDGET": {
@ -1439,6 +1433,7 @@
"boxShadow": "{{appsmith.theme.boxShadow.appBoxShadow}}"
},
"TEXT_WIDGET": {
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}"
},
"VIDEO_WIDGET": {

View File

@ -1276,7 +1276,7 @@ public class ApplicationServiceTest {
temp = new JSONArray();
temp.addAll(List.of(new JSONObject(Map.of("key", "testField1"))));
secondWidget.put("dynamicBindingPathList", temp);
secondWidget.put("testField1", "{{ testCollection1.cloneActionCollection1.data }}");
secondWidget.put("testField1", "{{ testCollection1.getData.data }}");
children.add(secondWidget);
Layout layout = testPage.getLayouts().get(0);
@ -1295,16 +1295,29 @@ public class ApplicationServiceTest {
"\t\tconst data = await cloneActionTest.run();\n" +
"\t\treturn data;\n" +
"\t}\n" +
"\tanotherMethod: async () => {\n" +
"\t\tconst data = await cloneActionTest.run();\n" +
"\t\treturn data;\n" +
"\t}\n" +
"}");
ActionDTO action1 = new ActionDTO();
action1.setName("cloneActionCollection1");
action1.setName("getData");
action1.setActionConfiguration(new ActionConfiguration());
action1.getActionConfiguration().setBody(
"async () => {\n" +
"\t\tconst data = await cloneActionTest.run();\n" +
"\t\treturn data;\n" +
"\t}");
actionCollectionDTO.setActions(List.of(action1));
ActionDTO action2 = new ActionDTO();
action2.setName("anotherMethod");
action2.setActionConfiguration(new ActionConfiguration());
action2.getActionConfiguration().setBody(
"async () => {\n" +
"\t\tconst data = await cloneActionTest.run();\n" +
"\t\treturn data;\n" +
"\t}");
actionCollectionDTO.setActions(List.of(action1, action2));
actionCollectionDTO.setPluginType(PluginType.JS);
return Mono.zip(
@ -1399,7 +1412,7 @@ public class ApplicationServiceTest {
});
});
assertThat(actionList).hasSize(2);
assertThat(actionList).hasSize(3);
actionList.forEach(newAction -> {
assertThat(newAction.getDefaultResources()).isNotNull();
assertThat(newAction.getDefaultResources().getActionId()).isEqualTo(newAction.getId());
@ -1422,7 +1435,7 @@ public class ApplicationServiceTest {
ActionCollectionDTO unpublishedCollection = actionCollection.getUnpublishedCollection();
assertThat(unpublishedCollection.getDefaultToBranchedActionIdsMap())
.hasSize(1);
.hasSize(2);
unpublishedCollection.getDefaultToBranchedActionIdsMap().keySet()
.forEach(key ->
assertThat(key).isEqualTo(unpublishedCollection.getDefaultToBranchedActionIdsMap().get(key))
@ -1512,6 +1525,241 @@ public class ApplicationServiceTest {
.verifyComplete();
}
@Test
@WithUserDetails(value = "api_user")
public void cloneApplication_withDeletedActionInActionCollection_deletedActionIsNotCloned() {
Application testApplication = new Application();
testApplication.setName("ApplicationServiceTest-clone-application-deleted-action-within-collection");
Mono<Application> originalApplicationMono = applicationPageService.createApplication(testApplication, orgId)
.cache();
Map<String, List<String>> originalResourceIds = new HashMap<>();
Mono<Application> resultMono = originalApplicationMono
.zipWhen(application -> newPageService.findPageById(application.getPages().get(0).getId(), READ_PAGES, false))
.flatMap(tuple -> {
Application application = tuple.getT1();
PageDTO testPage = tuple.getT2();
ActionDTO action = new ActionDTO();
action.setName("cloneActionTest");
action.setPageId(application.getPages().get(0).getId());
action.setExecuteOnLoad(true);
ActionConfiguration actionConfiguration = new ActionConfiguration();
actionConfiguration.setHttpMethod(HttpMethod.GET);
action.setActionConfiguration(actionConfiguration);
action.setDatasource(testDatasource);
// Save actionCollection
ActionCollectionDTO actionCollectionDTO = new ActionCollectionDTO();
actionCollectionDTO.setName("testCollection1");
actionCollectionDTO.setPageId(application.getPages().get(0).getId());
actionCollectionDTO.setApplicationId(application.getId());
actionCollectionDTO.setOrganizationId(application.getOrganizationId());
actionCollectionDTO.setPluginId(testPlugin.getId());
actionCollectionDTO.setVariables(List.of(new JSValue("test", "String", "test", true)));
actionCollectionDTO.setBody("export default {\n" +
"\tgetData: async () => {\n" +
"\t\tconst data = await cloneActionTest.run();\n" +
"\t\treturn data;\n" +
"\t},\n" +
"\tanotherMethod: async () => {\n" +
"\t\tconst data = await cloneActionTest.run();\n" +
"\t\treturn data;\n" +
"\t}\n" +
"}");
ActionDTO action1 = new ActionDTO();
action1.setName("getData");
action1.setActionConfiguration(new ActionConfiguration());
action1.getActionConfiguration().setBody(
"async () => {\n" +
"\t\tconst data = await cloneActionTest.run();\n" +
"\t\treturn data;\n" +
"\t}");
ActionDTO action2 = new ActionDTO();
action2.setName("anotherMethod");
action2.setActionConfiguration(new ActionConfiguration());
action2.getActionConfiguration().setBody(
"async () => {\n" +
"\t\tconst data = await cloneActionTest.run();\n" +
"\t\treturn data;\n" +
"\t}");
actionCollectionDTO.setActions(List.of(action1, action2));
actionCollectionDTO.setPluginType(PluginType.JS);
ObjectMapper objectMapper = new ObjectMapper();
JSONObject parentDsl = null;
try {
parentDsl = new JSONObject(objectMapper.readValue(DEFAULT_PAGE_LAYOUT, new TypeReference<HashMap<String, Object>>() {
}));
} catch (JsonProcessingException e) {
log.debug("Error while creating JSONObj from defaultPageLayout: ", e);
}
ArrayList children = (ArrayList) parentDsl.get("children");
JSONObject firstWidget = new JSONObject();
firstWidget.put("widgetName", "firstWidget");
JSONArray temp = new JSONArray();
temp.addAll(List.of(new JSONObject(Map.of("key", "testField"))));
firstWidget.put("dynamicBindingPathList", temp);
firstWidget.put("testField", "{{ cloneActionTest.data }}");
children.add(firstWidget);
JSONObject secondWidget = new JSONObject();
secondWidget.put("widgetName", "secondWidget");
temp = new JSONArray();
temp.addAll(List.of(new JSONObject(Map.of("key", "testField1"))));
secondWidget.put("dynamicBindingPathList", temp);
secondWidget.put("testField1", "{{ testCollection1.getData.data }}");
children.add(secondWidget);
JSONObject thirdWidget = new JSONObject();
thirdWidget.put("widgetName", "thirdWidget");
temp = new JSONArray();
temp.addAll(List.of(new JSONObject(Map.of("key", "testField1"))));
thirdWidget.put("dynamicBindingPathList", temp);
thirdWidget.put("testField1", "{{ testCollection1.anotherMethod.data }}");
children.add(thirdWidget);
Layout layout = testPage.getLayouts().get(0);
layout.setDsl(parentDsl);
return Mono.zip(
layoutCollectionService.createCollection(actionCollectionDTO),
layoutActionService.createSingleAction(action),
layoutActionService.updateLayout(testPage.getId(), layout.getId(), layout),
Mono.just(application)
);
})
.flatMap(tuple -> {
List<String> pageIds = new ArrayList<>(), collectionIds = new ArrayList<>();
ActionCollectionDTO collectionDTO = tuple.getT1();
collectionIds.add(collectionDTO.getId());
tuple.getT4().getPages().forEach(page -> pageIds.add(page.getId()));
originalResourceIds.put("pageIds", pageIds);
originalResourceIds.put("collectionIds", collectionIds);
String deletedActionIdWithinActionCollection = String
.valueOf(collectionDTO.getDefaultToBranchedActionIdsMap().values().stream().findAny().orElse(null));
return newActionService.deleteUnpublishedAction(deletedActionIdWithinActionCollection)
.thenMany(newActionService.findAllByApplicationIdAndViewMode(tuple.getT4().getId(), false, READ_ACTIONS, null))
.collectList()
.flatMap(actionList -> {
List<String> actionIds = actionList.stream().map(BaseDomain::getId).collect(Collectors.toList());
originalResourceIds.put("actionIds", actionIds);
return applicationPageService.cloneApplication(tuple.getT4().getId(), null);
});
})
.cache();
Policy manageAppPolicy = Policy.builder().permission(MANAGE_APPLICATIONS.getValue())
.users(Set.of("api_user"))
.build();
Policy readAppPolicy = Policy.builder().permission(READ_APPLICATIONS.getValue())
.users(Set.of("api_user"))
.build();
Policy managePagePolicy = Policy.builder().permission(MANAGE_PAGES.getValue())
.users(Set.of("api_user"))
.build();
Policy readPagePolicy = Policy.builder().permission(READ_PAGES.getValue())
.users(Set.of("api_user"))
.build();
StepVerifier.create(resultMono
.zipWhen(application -> Mono.zip(
newActionService.findAllByApplicationIdAndViewMode(application.getId(), false, READ_ACTIONS, null).collectList(),
actionCollectionService.findAllByApplicationIdAndViewMode(application.getId(), false, READ_ACTIONS, null).collectList(),
newPageService.findNewPagesByApplicationId(application.getId(), READ_PAGES).collectList()
)))
.assertNext(tuple -> {
Application application = tuple.getT1(); // cloned application
List<NewAction> actionList = tuple.getT2().getT1();
List<ActionCollection> actionCollectionList = tuple.getT2().getT2();
List<NewPage> pageList = tuple.getT2().getT3();
assertThat(application).isNotNull();
assertThat(application.isAppIsExample()).isFalse();
assertThat(application.getId()).isNotNull();
assertThat(application.getName().equals("ApplicationServiceTest Clone Source TestApp Copy"));
assertThat(application.getPolicies()).containsAll(Set.of(manageAppPolicy, readAppPolicy));
assertThat(application.getOrganizationId().equals(orgId));
assertThat(application.getModifiedBy()).isEqualTo("api_user");
assertThat(application.getUpdatedAt()).isNotNull();
List<ApplicationPage> pages = application.getPages();
Set<String> pageIdsFromApplication = pages.stream().map(page -> page.getId()).collect(Collectors.toSet());
Set<String> pageIdsFromDb = pageList.stream().map(page -> page.getId()).collect(Collectors.toSet());
assertThat(pageIdsFromApplication.containsAll(pageIdsFromDb));
assertThat(pageList).isNotEmpty();
for (NewPage page : pageList) {
assertThat(page.getPolicies()).containsAll(Set.of(managePagePolicy, readPagePolicy));
assertThat(page.getApplicationId()).isEqualTo(application.getId());
}
assertThat(pageList).isNotEmpty();
pageList.forEach(newPage -> {
assertThat(newPage.getDefaultResources()).isNotNull();
assertThat(newPage.getDefaultResources().getPageId()).isEqualTo(newPage.getId());
assertThat(newPage.getDefaultResources().getApplicationId()).isEqualTo(application.getId());
newPage.getUnpublishedPage()
.getLayouts()
.forEach(layout -> {
assertThat(layout.getLayoutOnLoadActions()).hasSize(1);
layout.getLayoutOnLoadActions().forEach(dslActionDTOS -> {
assertThat(dslActionDTOS).hasSize(2);
dslActionDTOS.forEach(actionDTO -> {
assertThat(actionDTO.getId()).isEqualTo(actionDTO.getDefaultActionId());
if (StringUtils.hasLength(actionDTO.getCollectionId())) {
assertThat(actionDTO.getDefaultCollectionId()).isEqualTo(actionDTO.getCollectionId());
}
});
});
});
});
assertThat(actionList).hasSize(2);
actionList.forEach(newAction -> {
assertThat(newAction.getDefaultResources()).isNotNull();
assertThat(newAction.getDefaultResources().getActionId()).isEqualTo(newAction.getId());
assertThat(newAction.getDefaultResources().getApplicationId()).isEqualTo(application.getId());
ActionDTO action = newAction.getUnpublishedAction();
assertThat(action.getDefaultResources()).isNotNull();
assertThat(action.getDefaultResources().getPageId()).isEqualTo(application.getPages().get(0).getId());
if (!StringUtils.isEmpty(action.getDefaultResources().getCollectionId())) {
assertThat(action.getDefaultResources().getCollectionId()).isEqualTo(action.getCollectionId());
}
});
assertThat(actionCollectionList).hasSize(1);
actionCollectionList.forEach(actionCollection -> {
assertThat(actionCollection.getDefaultResources()).isNotNull();
assertThat(actionCollection.getDefaultResources().getCollectionId()).isEqualTo(actionCollection.getId());
assertThat(actionCollection.getDefaultResources().getApplicationId()).isEqualTo(application.getId());
ActionCollectionDTO unpublishedCollection = actionCollection.getUnpublishedCollection();
// We should have single entry as other action is deleted from the parent application
assertThat(unpublishedCollection.getDefaultToBranchedActionIdsMap()).hasSize(1);
unpublishedCollection.getDefaultToBranchedActionIdsMap().keySet()
.forEach(key ->
assertThat(key).isEqualTo(unpublishedCollection.getDefaultToBranchedActionIdsMap().get(key))
);
assertThat(unpublishedCollection.getDefaultResources()).isNotNull();
assertThat(unpublishedCollection.getDefaultResources().getPageId())
.isEqualTo(application.getPages().get(0).getId());
});
})
.verifyComplete();
}
@Test
@WithUserDetails(value = "api_user")
public void basicPublishApplicationTest() {

Some files were not shown because too many files have changed in this diff Show More