Merge branch 'release'

This commit is contained in:
Shrikant Sharat Kandula 2020-08-24 17:37:44 +05:30
commit 66aed61d16
21 changed files with 352 additions and 36 deletions

View File

@ -1,11 +1,6 @@
const commonlocators = require("../../../locators/commonlocators.json");
const dsl = require("../../../fixtures/commondsl.json");
const widgetsPage = require("../../../locators/Widgets.json");
const testdata = require("../../../fixtures/testdata.json");
const pages = require("../../../locators/Pages.json");
const dsl = require("../../../fixtures/displayWidgetDsl.json");
const apiwidget = require("../../../locators/apiWidgetslocator.json");
const explorer = require("../../../locators/explorerlocators.json");
const pageid = "MyPage";
describe("Entity explorer tests related to widgets and validation", function() {
beforeEach(() => {
@ -13,31 +8,26 @@ describe("Entity explorer tests related to widgets and validation", function() {
});
it("Widget edit/delete/copy to clipboard validation", function() {
cy.openPropertyPane("textwidget");
cy.widgetText("Api", widgetsPage.textWidget, widgetsPage.textInputval);
cy.testCodeMirror("/api/users/2");
cy.NavigateToEntityExplorer();
cy.wait(5000);
cy.SearchEntityandOpen("Api");
cy.SearchEntityandOpen("Text1");
cy.get(explorer.collapse)
.last()
.click({ force: true });
cy.get(explorer.property)
.last()
.click({ force: true });
cy.wait(2000);
cy.get(apiwidget.propertyList).then(function($lis) {
expect($lis).to.have.length(2);
expect($lis.eq(0)).to.contain("{{Api.isVisible}}");
expect($lis.eq(1)).to.contain("{{Api.text}}");
expect($lis.eq(0)).to.contain("{{Text1.isVisible}}");
expect($lis.eq(1)).to.contain("{{Text1.text}}");
});
cy.GlobalSearchEntity("Api");
cy.EditApiNameFromExplorer("ApiUpdated");
cy.GlobalSearchEntity("ApiUpdated");
cy.GlobalSearchEntity("Text1");
cy.EditApiNameFromExplorer("TextUpdated");
cy.GlobalSearchEntity("TextUpdated");
cy.get(apiwidget.propertyList).then(function($lis) {
expect($lis).to.have.length(2);
expect($lis.eq(0)).to.contain("{{ApiUpdated.isVisible}}");
expect($lis.eq(1)).to.contain("{{ApiUpdated.text}}");
expect($lis.eq(0)).to.contain("{{TextUpdated.isVisible}}");
expect($lis.eq(1)).to.contain("{{TextUpdated.text}}");
});
cy.DeleteWidgetFromSideBar();
});

View File

@ -0,0 +1,18 @@
const pages = require("../../../locators/Pages.json");
describe("Pages", function() {
it("Clone page", function() {
cy.xpath(pages.popover)
.last()
.click({ force: true });
cy.get(pages.clonePage).click({ force: true });
cy.wait("@clonePage").should(
"have.nested.property",
"response.body.responseMeta.status",
201,
);
cy.get(".t--entity-name:contains(Page1 Copy)");
});
});

View File

@ -16,6 +16,7 @@
"entityExplorer": ".t--nav-link-entity-explorer",
"popover": "//div[contains(@class,'t--entity page')]//*[local-name()='g' and @id='Icon/Outline/more-vertical']",
"editName": ".single-select >div:contains('Edit Name')",
"clonePage": ".single-select >div:contains('Clone')",
"deletePage": ".single-select >div:contains('Delete')",
"entityQuery": ".t--entity-name:contains('Queries')"
}

View File

@ -1478,6 +1478,7 @@ Cypress.Commands.add("startServerAndRoutes", () => {
);
cy.route("GET", "/api/v1/users/me").as("getUser");
cy.route("POST", "/api/v1/pages").as("createPage");
cy.route("POST", "/api/v1/pages/clone/*").as("clonePage");
});
Cypress.Commands.add("alertValidate", text => {

View File

@ -105,6 +105,30 @@ export const createPage = (applicationId: string, pageName: string) => {
};
};
export const clonePageInit = (pageId: string) => {
return {
type: ReduxActionTypes.CLONE_PAGE_INIT,
payload: {
id: pageId,
},
};
};
export const clonePageSuccess = (
pageId: string,
pageName: string,
layoutId: string,
) => {
return {
type: ReduxActionTypes.CLONE_PAGE_SUCCESS,
payload: {
pageId,
pageName,
layoutId,
},
};
};
export const updatePage = (id: string, name: string) => {
return {
type: ReduxActionTypes.UPDATE_PAGE_INIT,

View File

@ -78,6 +78,10 @@ export interface DeletePageRequest {
id: string;
}
export interface ClonePageRequest {
id: string;
}
export interface UpdateWidgetNameRequest {
pageId: string;
layoutId: string;
@ -150,6 +154,10 @@ class PageApi extends Api {
return Api.delete(PageApi.url + "/" + request.id);
}
static clonePage(request: ClonePageRequest): AxiosPromise<ApiResponse> {
return Api.post(PageApi.url + "/clone/" + request.id);
}
static updateWidgetName(
request: UpdateWidgetNameRequest,
): AxiosPromise<UpdateWidgetNameResponse> {

View File

@ -0,0 +1,109 @@
import React, { useCallback, useState } from "react";
import { CommonComponentProps, hexToRgba } from "./common";
import { ReactComponent as DownArrow } from "../../assets/icons/ads/down_arrow.svg";
import Text, { TextType } from "./Text";
import styled from "styled-components";
type DropdownOption = {
label: string;
value: string;
};
type DropdownProps = CommonComponentProps & {
options: DropdownOption[];
onSelect: (selectedValue: string) => void;
selectedOption: DropdownOption;
};
const DropdownWrapper = styled.div`
width: 100%;
position: relative;
`;
const SelectedItem = styled.div`
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
span {
margin-right: ${props => props.theme.spaces[1] + 1}px;
}
`;
const OptionsWrapper = styled.div`
position: absolute;
margin-top: ${props => props.theme.spaces[8]}px;
left: -60px;
width: 200px;
display: flex;
flex-direction: column;
background-color: ${props => props.theme.colors.blackShades[3]};
box-shadow: ${props => props.theme.spaces[0]}px
${props => props.theme.spaces[5]}px ${props => props.theme.spaces[13] - 2}px
${props => hexToRgba(props.theme.colors.blackShades[0], 0.75)};
`;
const DropdownOption = styled.div<{
selected: DropdownOption;
option: DropdownOption;
}>`
display: flex;
flex-direction: column;
padding: 10px 12px;
cursor: pointer;
background-color: ${props =>
props.option.label === props.selected.label
? props.theme.colors.blackShades[4]
: "transparent"};
span:last-child {
margin-top: ${props => props.theme.spaces[1] + 1}px;
}
&:hover {
span {
color: ${props => props.theme.colors.blackShades[9]};
}
}
`;
const TableDropdown = (props: DropdownProps) => {
const [selected, setSelected] = useState(props.selectedOption);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownHandler = () => {
setIsDropdownOpen(!isDropdownOpen);
};
const optionSelector = (option: DropdownOption) => {
setSelected(option);
setIsDropdownOpen(false);
};
return (
<DropdownWrapper>
<SelectedItem onClick={() => dropdownHandler()}>
<Text type={TextType.P1}>{selected.label}</Text>
<DownArrow />
</SelectedItem>
{isDropdownOpen ? (
<OptionsWrapper>
{props.options.map((el: DropdownOption, index: number) => (
<DropdownOption
key={index}
selected={selected}
option={el}
onClick={() => optionSelector(el)}
>
<Text type={TextType.H5}>{el.label}</Text>
<Text type={TextType.P3}>{el.value}</Text>
</DropdownOption>
))}
</OptionsWrapper>
) : null}
</DropdownWrapper>
);
};
export default TableDropdown;

View File

@ -0,0 +1,37 @@
import React from "react";
import { withKnobs, select, boolean, text } from "@storybook/addon-knobs";
import { withDesign } from "storybook-addon-designs";
import TableDropdown from "../ads/TableDropdown";
export default {
title: "Dropdown",
component: TableDropdown,
decorators: [withKnobs, withDesign],
};
const options = [
{
label: "Admin",
value: "Can edit, view and invite other user to an app",
},
{
label: "Developer",
value: "Can view and invite other user to an app",
},
{
label: "User",
value: "Can view and invite other user to an app and...",
},
];
export const TableDropdownStory = () => (
<div
style={{ padding: "50px 200px", height: "500px", background: "#1A191C" }}
>
<TableDropdown
options={options}
onSelect={(selectedValue: string) => console.log(selectedValue)}
selectedOption={options[0]}
></TableDropdown>
</div>
);

View File

@ -161,6 +161,8 @@ export const ReduxActionTypes: { [key: string]: string } = {
DELETE_APPLICATION_SUCCESS: "DELETE_APPLICATION_SUCCESS",
DELETE_PAGE_INIT: "DELETE_PAGE_INIT",
DELETE_PAGE_SUCCESS: "DELETE_PAGE_SUCCESS",
CLONE_PAGE_INIT: "CLONE_PAGE_INIT",
CLONE_PAGE_SUCCESS: "CLONE_PAGE_SUCCESS",
SET_DEFAULT_APPLICATION_PAGE_INIT: "SET_DEFAULT_APPLICATION_PAGE_INIT",
SET_DEFAULT_APPLICATION_PAGE_SUCCESS: "SET_DEFAULT_APPLICATION_PAGE_SUCCESS",
CREATE_ORGANIZATION_INIT: "CREATE_ORGANIZATION_INIT",
@ -312,6 +314,7 @@ export const ReduxActionErrorTypes: { [key: string]: string } = {
MOVE_ACTION_ERROR: "MOVE_ACTION_ERROR",
COPY_ACTION_ERROR: "COPY_ACTION_ERROR",
DELETE_PAGE_ERROR: "DELETE_PAGE_ERROR",
CLONE_PAGE_ERROR: "CLONE_PAGE_ERROR",
DELETE_APPLICATION_ERROR: "DELETE_APPLICATION_ERROR",
SET_DEFAULT_APPLICATION_PAGE_ERROR: "SET_DEFAULT_APPLICATION_PAGE_ERROR",
CREATE_ORGANIZATION_ERROR: "CREATE_ORGANIZATION_ERROR",
@ -394,6 +397,13 @@ export interface Page {
latest?: boolean;
}
export interface ClonePageSuccessPayload {
pageName: string;
pageId: string;
layoutId: string;
isDefault: boolean;
}
export type PageListPayload = Array<Page>;
export type ApplicationPayload = {

View File

@ -125,6 +125,11 @@ export const WidgetIcons: {
<ModalIcon />
</IconWrapper>
),
FORM_BUTTON_WIDGET: (props: IconProps) => (
<IconWrapper {...props}>
<ButtonIcon />
</IconWrapper>
),
};
export type WidgetIcon = typeof WidgetIcons[keyof typeof WidgetIcons];

View File

@ -4,6 +4,7 @@ import {
JSExecutorResult,
} from "./JSExecutionManagerSingleton";
import JSONFn from "json-fn";
import log from "loglevel";
declare let Realm: any;
export default class RealmExecutor implements JSExecutor {
@ -104,7 +105,8 @@ export default class RealmExecutor implements JSExecutor {
triggers,
};
} catch (e) {
// console.error(`Error: "${e.message}" when evaluating {{${sourceText}}}`);
log.debug(`Error: "${e.message}" when evaluating {{${sourceText}}}`);
log.debug(e);
return { result: undefined, triggers: [] };
}
}

View File

@ -151,6 +151,7 @@ const Control = styled.div<{ fixed?: boolean }>`
.${Classes.BUTTON_TEXT} {
font-size: 12px;
color: white;
}
.more {
@ -269,7 +270,7 @@ export const ApplicationCard = (props: ApplicationCardProps) => {
{hasEditPermission && (
<Button
onClick={() => history.push(editApplicationURL)}
href={editApplicationURL}
filled
text="EDIT"
intent="primary"
@ -285,8 +286,8 @@ export const ApplicationCard = (props: ApplicationCardProps) => {
/>
)}
<Button
onClick={() => history.push(viewApplicationURL)}
intent="none"
href={viewApplicationURL}
outline
fluid
text="LAUNCH"

View File

@ -9,6 +9,7 @@ import { ReduxActionTypes } from "constants/ReduxActionConstants";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { ContextMenuPopoverModifiers } from "../helpers";
import { initExplorerEntityNameEdit } from "actions/explorerActions";
import { clonePageInit } from "actions/pageActions";
export const PageContextMenu = (props: {
pageId: string;
@ -52,12 +53,22 @@ export const PageContextMenu = (props: {
[dispatch, props.pageId],
);
const clonePage = useCallback(() => dispatch(clonePageInit(props.pageId)), [
dispatch,
props.pageId,
]);
const optionTree: TreeDropdownOption[] = [
{
value: "rename",
onSelect: editPageName,
label: "Edit Name",
},
{
value: "clone",
onSelect: clonePage,
label: "Clone",
},
];
if (!props.isDefaultPage) {
optionTree.push({

View File

@ -56,7 +56,6 @@ export default (props: PopperProps) => {
},
},
);
_popper.disableEventListeners();
return () => {
_popper.destroy();
};

View File

@ -3,6 +3,7 @@ import {
ReduxAction,
ReduxActionTypes,
PageListPayload,
ClonePageSuccessPayload,
} from "constants/ReduxActionConstants";
const initialState: PageListReduxState = {
@ -51,6 +52,17 @@ const pageListReducer = createReducer(initialState, {
_state.pages.push({ ...action.payload, latest: true });
return { ..._state };
},
[ReduxActionTypes.CLONE_PAGE_SUCCESS]: (
state: PageListReduxState,
action: ReduxAction<ClonePageSuccessPayload>,
): PageListReduxState => {
return {
...state,
pages: state.pages
.map(page => ({ ...page, latest: false }))
.concat([{ ...action.payload, latest: true }]),
};
},
[ReduxActionTypes.SET_DEFAULT_APPLICATION_PAGE_SUCCESS]: (
state: PageListReduxState,
action: ReduxAction<{ pageId: string; applicationId: string }>,

View File

@ -22,6 +22,8 @@ const initialState: EditorReduxState = {
isPageSwitching: false,
creatingPage: false,
creatingPageError: false,
cloningPage: false,
cloningPageError: false,
updatingWidgetName: false,
updateWidgetNameError: false,
},
@ -111,6 +113,20 @@ const editorReducer = createReducer(initialState, {
currentApplicationId,
};
},
[ReduxActionTypes.CLONE_PAGE_INIT]: (state: EditorReduxState) => {
state.loadingStates.cloningPage = true;
state.loadingStates.cloningPageError = false;
return { ...state };
},
[ReduxActionTypes.CLONE_PAGE_ERROR]: (state: EditorReduxState) => {
state.loadingStates.cloningPageError = true;
state.loadingStates.cloningPage = false;
return { ...state };
},
[ReduxActionTypes.CLONE_PAGE_SUCCESS]: (state: EditorReduxState) => {
state.loadingStates.cloningPage = false;
return { ...state };
},
[ReduxActionTypes.CREATE_PAGE_INIT]: (state: EditorReduxState) => {
state.loadingStates.creatingPage = true;
state.loadingStates.creatingPageError = false;
@ -162,6 +178,8 @@ export interface EditorReduxState {
pageSwitchingError: boolean;
creatingPage: boolean;
creatingPageError: boolean;
cloningPage: boolean;
cloningPageError: boolean;
updatingWidgetName: boolean;
updateWidgetNameError: boolean;
};

View File

@ -32,6 +32,10 @@ const explorerReducer = createReducer(initialState, {
[ReduxActionTypes.FETCH_PAGE_ERROR]: setEntityUpdateError,
[ReduxActionTypes.FETCH_PAGE_SUCCESS]: setEntityUpdateSuccess,
[ReduxActionTypes.CLONE_PAGE_INIT]: setUpdatingEntity,
[ReduxActionTypes.CLONE_PAGE_ERROR]: setEntityUpdateError,
[ReduxActionTypes.CLONE_PAGE_SUCCESS]: setEntityUpdateSuccess,
[ReduxActionTypes.MOVE_ACTION_INIT]: setUpdatingEntity,
[ReduxActionErrorTypes.MOVE_ACTION_ERROR]: setEntityUpdateError,
[ReduxActionTypes.MOVE_ACTION_SUCCESS]: setEntityUpdateSuccess,

View File

@ -10,6 +10,7 @@ import {
} from "constants/ReduxActionConstants";
import {
deletePageSuccess,
clonePageSuccess,
fetchPageSuccess,
fetchPublishedPageSuccess,
savePageSuccess,
@ -31,6 +32,7 @@ import PageApi, {
UpdatePageRequest,
UpdateWidgetNameRequest,
UpdateWidgetNameResponse,
ClonePageRequest,
} from "api/PageApi";
import { FlattenedWidgetProps } from "reducers/entityReducers/canvasWidgetsReducer";
import {
@ -382,6 +384,40 @@ export function* deletePageSaga(action: ReduxAction<DeletePageRequest>) {
}
}
export function* clonePageSaga(clonePageAction: ReduxAction<ClonePageRequest>) {
try {
const request: ClonePageRequest = clonePageAction.payload;
const response: FetchPageResponse = yield call(PageApi.clonePage, request);
const applicationId = yield select(
(state: AppState) => state.entities.pageList.applicationId,
);
const isValidResponse = yield validateResponse(response);
if (isValidResponse) {
yield put(
clonePageSuccess(
response.data.id,
response.data.name,
response.data.layouts[0].id,
),
);
yield put({
type: ReduxActionTypes.FETCH_PAGE_DSL_INIT,
payload: {
pageId: response.data.id,
},
});
history.push(BUILDER_PAGE_URL(applicationId, response.data.id));
}
} catch (error) {
yield put({
type: ReduxActionErrorTypes.CLONE_PAGE_ERROR,
payload: {
error,
},
});
}
}
export function* updateWidgetNameSaga(
action: ReduxAction<{ id: string; newName: string }>,
) {
@ -477,6 +513,7 @@ export default function* pageSagas() {
),
takeLatest(ReduxActionTypes.UPDATE_LAYOUT, saveLayoutSaga),
takeLeading(ReduxActionTypes.CREATE_PAGE_INIT, createPageSaga),
takeLeading(ReduxActionTypes.CLONE_PAGE_INIT, clonePageSaga),
takeLatest(ReduxActionTypes.FETCH_PAGE_LIST_INIT, fetchPageListSaga),
takeLatest(ReduxActionTypes.UPDATE_PAGE_INIT, updatePageSaga),
takeLatest(ReduxActionTypes.DELETE_PAGE_INIT, deletePageSaga),

View File

@ -2,12 +2,16 @@ import React from "react";
import BaseWidget, { WidgetProps, WidgetState } from "./BaseWidget";
import { TriggerPropertiesMap } from "utils/WidgetFactory";
import { WidgetType, WidgetTypes } from "constants/WidgetConstants";
import styled from "styled-components";
import IconComponent, {
IconType,
} from "components/designSystems/appsmith/IconComponent";
import { EventType, ExecutionResult } from "constants/ActionConstants";
const IconWrapper = styled.div`
display: flex;
justify-content: flex-end;
`;
class IconWidget extends BaseWidget<IconWidgetProps, WidgetState> {
static getTriggerPropertyMap(): TriggerPropertiesMap {
return {
@ -32,13 +36,15 @@ class IconWidget extends BaseWidget<IconWidgetProps, WidgetState> {
getPageView() {
return (
<IconComponent
iconName={this.props.iconName}
disabled={this.props.disabled}
iconSize={this.props.iconSize}
color={this.props.color}
onClick={this.onClick}
/>
<IconWrapper>
<IconComponent
iconName={this.props.iconName}
disabled={this.props.disabled}
iconSize={this.props.iconSize}
color={this.props.color}
onClick={this.onClick}
/>
</IconWrapper>
);
}

View File

@ -93,7 +93,7 @@ class TableWidget extends BaseWidget<TableWidgetProps, WidgetState> {
pageSize: undefined,
selectedRowIndex: -1,
searchText: undefined,
selectedRow: undefined,
selectedRow: {},
// The following meta property is used for rendering the table.
filteredTableData: undefined,
};

View File

@ -14,6 +14,7 @@ import com.appsmith.external.plugins.BasePlugin;
import com.appsmith.external.plugins.PluginExecutor;
import com.mongodb.MongoClient;
import com.mongodb.MongoClientURI;
import com.mongodb.MongoCommandException;
import com.mongodb.MongoTimeoutException;
import com.mongodb.client.ClientSession;
import com.mongodb.client.MongoDatabase;
@ -299,19 +300,41 @@ public class MongoPlugin extends BasePlugin {
@Override
public Mono<DatasourceTestResult> testDatasource(DatasourceConfiguration datasourceConfiguration) {
final Connection.Type connectionType = datasourceConfiguration.getConnection().getType();
return datasourceCreate(datasourceConfiguration)
.map(mongoClient -> {
.map(mongoClientObj -> {
final MongoClient mongoClient = (MongoClient) mongoClientObj;
ClientSession clientSession = null;
try {
// Not using try-with-resources here since we want to close the *session* before closing the
// MongoClient instance.
clientSession = ((MongoClient) mongoClient).startSession();
if (Connection.Type.REPLICA_SET.equals(connectionType)) {
// For REPLICA_SET connections, we check by creating a session, as this is faster.
clientSession = mongoClient.startSession();
} else {
// For DIRECT connections, we check by running a DB command, as it's the only reliable
// method of checking if the connection is usable.
mongoClient
.getDatabase("admin")
.runCommand(new Document("listDatabases", 1));
return new DatasourceTestResult();
}
} catch (MongoTimeoutException e) {
log.warn("Timeout connecting to MongoDB from MongoPlugin.", e);
return new DatasourceTestResult("Timed out trying to connect to MongoDB host.");
} catch(MongoCommandException e) {
// The fact that we got a response saying "Unauthorized" means that the connection to the
// MongoDB instance is valid. It also means we don't have access to the admin database, but
// that's okay for our purposes here.
return "Unauthorized".equals(e.getErrorCodeName())
? new DatasourceTestResult()
: new DatasourceTestResult(e.getMessage());
} catch (Exception e) {
return new DatasourceTestResult(e.getMessage());
@ -319,8 +342,8 @@ public class MongoPlugin extends BasePlugin {
if (clientSession != null) {
clientSession.close();
}
if (mongoClient instanceof MongoClient) {
((MongoClient) mongoClient).close();
if (mongoClient != null) {
mongoClient.close();
}
}