diff --git a/app/client/Concepts.md b/app/client/Concepts.md deleted file mode 100644 index 90336cfeef..0000000000 --- a/app/client/Concepts.md +++ /dev/null @@ -1,16 +0,0 @@ -Concepts -======== -Widgets - - WidgetProperties - - WIDGET_PROPERTY_LABEL (STRING) - - WIDGET_PROPERTY_TYPE (STRING) - - WIDGET_PROPERTY_DEFAULT (STRING) - - WIDGET_PROPERTY_ENABLED (BOOLEAN) - - WIDGET_PROPERTY_META - - ALLOWED_VALUES (LIST) - - Components - - WidgetCards - - WIDGET_TYPE (STRING) - - WIDGET_CARD_LABEL (STRING) - - WIDGET_CARD_ENABLED (BOOLEAN) - - WIDGET_CARD_ICON (UTF-16) \ No newline at end of file diff --git a/app/client/package.json b/app/client/package.json index ea1747041d..3535b73cda 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -86,7 +86,7 @@ "scripts": { "analyze": "source-map-explorer 'build/static/js/*.js'", "start": "REACT_APP_ENVIRONMENT=DEVELOPMENT HOST=dev.appsmith.com craco start", - "build": "craco --max-old-space-size=2048 build", + "build": "craco --max-old-space-size=2048 build", "test": "CI=true craco test", "eject": "react-scripts eject", "start-prod": "REACT_APP_ENVIRONMENT=PRODUCTION craco start", diff --git a/app/client/src/actions/userActions.ts b/app/client/src/actions/userActions.ts new file mode 100644 index 0000000000..de3b81aedf --- /dev/null +++ b/app/client/src/actions/userActions.ts @@ -0,0 +1,59 @@ +import { + ReduxActionTypes, + ReduxActionErrorTypes, +} from "constants/ReduxActionConstants"; +import { CurrentUserDetailsRequestPayload } from "constants/userConstants"; +import { VerifyTokenRequest, TokenPasswordUpdateRequest } from "api/UserApi"; + +export const logoutUserSuccess = () => ({ + type: ReduxActionTypes.LOGOUT_USER_SUCCESS, +}); + +export const logoutUserError = (error: any) => ({ + type: ReduxActionErrorTypes.LOGOUT_USER_ERROR, + payload: { + error, + }, +}); + +export const fetchCurrentUser = () => ({ + type: ReduxActionTypes.FETCH_USER_INIT, + payload: CurrentUserDetailsRequestPayload, +}); + +export const setCurrentUserDetails = () => ({ + type: ReduxActionTypes.SET_CURRENT_USER_INIT, + payload: CurrentUserDetailsRequestPayload, +}); + +export const verifyInviteSuccess = () => ({ + type: ReduxActionTypes.VERIFY_INVITE_SUCCESS, +}); + +export const verifyInvite = (payload: VerifyTokenRequest) => ({ + type: ReduxActionTypes.VERIFY_INVITE_INIT, + payload, +}); + +export const verifyInviteError = (error: any) => ({ + type: ReduxActionErrorTypes.VERIFY_INVITE_ERROR, + payload: { error }, +}); + +export const invitedUserSignup = ( + payload: TokenPasswordUpdateRequest & { resolve: any; reject: any }, +) => ({ + type: ReduxActionTypes.INVITED_USER_SIGNUP, + payload, +}); + +export const invitedUserSignupSuccess = () => ({ + type: ReduxActionTypes.INVITED_USER_SIGNUP_SUCCESS, +}); + +export const invitedUserSignupError = (error: any) => ({ + type: ReduxActionErrorTypes.INVITED_USER_SIGNUP_ERROR, + payload: { + error, + }, +}); diff --git a/app/client/src/api/ActionAPI.tsx b/app/client/src/api/ActionAPI.tsx index 7f05081275..bfc76435d8 100644 --- a/app/client/src/api/ActionAPI.tsx +++ b/app/client/src/api/ActionAPI.tsx @@ -57,6 +57,7 @@ export interface RestAction { pageId?: string; actionConfiguration: Partial; jsonPathKeys: string[]; + cacheResponse?: string; } export interface ExecuteActionRequest extends APIRequest { diff --git a/app/client/src/api/UserApi.tsx b/app/client/src/api/UserApi.tsx index a1e5cee86d..26e2a10feb 100644 --- a/app/client/src/api/UserApi.tsx +++ b/app/client/src/api/UserApi.tsx @@ -1,6 +1,7 @@ import { AxiosPromise } from "axios"; import Api from "./Api"; import { ApiResponse } from "./ApiResponses"; +import { getAppsmithConfigs } from "configs"; export interface LoginUserRequest { email: string; @@ -21,15 +22,13 @@ export interface ForgotPasswordRequest { email: string; } -export interface ResetPasswordRequest { +export interface TokenPasswordUpdateRequest { token: string; - user: { - password: string; - email: string; - }; + password: string; + email: string; } -export interface ResetPasswordVerifyTokenRequest { +export interface VerifyTokenRequest { email: string; token: string; } @@ -49,45 +48,67 @@ export interface InviteUserRequest { } class UserApi extends Api { - //TODO(abhinav): make a baseURL, to which the other paths are added. - static createURL = "v1/users"; - static forgotPasswordURL = "v1/users/forgotPassword"; - static verifyResetPasswordTokenURL = "v1/users/verifyPasswordResetToken"; - static resetPasswordURL = "v1/users/resetPassword"; - static fetchUserURL = "v1/users"; + static usersURL = "v1/users"; + static forgotPasswordURL = `${UserApi.usersURL}/forgotPassword`; + static verifyResetPasswordTokenURL = `${UserApi.usersURL}/verifyPasswordResetToken`; + static resetPasswordURL = `${UserApi.usersURL}/resetPassword`; static inviteUserURL = "v1/users/invite"; + static verifyInviteTokenURL = `${UserApi.inviteUserURL}/verify`; + static confirmUserInviteURL = `${UserApi.inviteUserURL}/confirm`; + static logoutURL = "/logout"; + static createUser( request: CreateUserRequest, ): AxiosPromise { - return Api.post(UserApi.createURL, request); + return Api.post(UserApi.usersURL, request); + } + + static fetchUser(request: FetchUserRequest): AxiosPromise { + return Api.get(UserApi.usersURL + "/" + request.id); } static forgotPassword( request: ForgotPasswordRequest, ): AxiosPromise { - return Api.get(UserApi.forgotPasswordURL, request); - } - - static resetPassword( - request: ResetPasswordRequest, - ): AxiosPromise { - return Api.put(UserApi.resetPasswordURL, request); + return Api.post(UserApi.forgotPasswordURL, request); } static verifyResetPasswordToken( - request: ResetPasswordVerifyTokenRequest, + request: VerifyTokenRequest, ): AxiosPromise { return Api.get(UserApi.verifyResetPasswordTokenURL, request); } - static fetchUser(request: FetchUserRequest): AxiosPromise { - return Api.get(UserApi.fetchUserURL + "/" + request.id); + static resetPassword( + request: TokenPasswordUpdateRequest, + ): AxiosPromise { + return Api.put(UserApi.resetPasswordURL, request); } static inviteUser(request: InviteUserRequest): AxiosPromise { request.status = "INVITED"; return Api.post(UserApi.inviteUserURL, request); } + + static verifyUserInvite( + request: VerifyTokenRequest, + ): AxiosPromise { + return Api.get(UserApi.verifyInviteTokenURL, request); + } + + static confirmInvitedUserSignup( + request: TokenPasswordUpdateRequest, + ): AxiosPromise { + return Api.put(UserApi.confirmUserInviteURL, request); + } + + static logoutUser(): AxiosPromise { + const { baseUrl } = getAppsmithConfigs(); + return Api.post(UserApi.logoutURL, undefined, undefined, { + baseURL: baseUrl, + withCredentials: true, + }); + } } export default UserApi; diff --git a/app/client/src/components/designSystems/appsmith/CreatableDropdown.tsx b/app/client/src/components/designSystems/appsmith/CreatableDropdown.tsx index 5e4340bff1..9182c6e375 100644 --- a/app/client/src/components/designSystems/appsmith/CreatableDropdown.tsx +++ b/app/client/src/components/designSystems/appsmith/CreatableDropdown.tsx @@ -14,9 +14,14 @@ type DropdownProps = { meta: WrappedFieldMetaProps; onCreateOption: (inputValue: string) => void; formatCreateLabel?: (value: string) => React.ReactNode; + noOptionsMessage?: (obj: { inputValue: string }) => string; }; const selectStyles = { + placeholder: (provided: any) => ({ + ...provided, + color: "#a3b3bf", + }), singleValue: (provided: any) => ({ ...provided, backgroundColor: "rgba(104,113,239,0.1)", @@ -67,9 +72,11 @@ class CreatableDropdown extends React.Component { onCreateOption, input, formatCreateLabel, + noOptionsMessage, } = this.props; const optionalProps: Partial = {}; if (formatCreateLabel) optionalProps.formatCreateLabel = formatCreateLabel; + if (noOptionsMessage) optionalProps.noOptionsMessage = noOptionsMessage; return ( ({ + ...provided, + color: "#a3b3bf", + }), control: (styles: any, state: any) => ({ ...styles, width: 100, diff --git a/app/client/src/components/designSystems/blueprint/ButtonComponent.tsx b/app/client/src/components/designSystems/blueprint/ButtonComponent.tsx index 7f35c43553..12fb85a689 100644 --- a/app/client/src/components/designSystems/blueprint/ButtonComponent.tsx +++ b/app/client/src/components/designSystems/blueprint/ButtonComponent.tsx @@ -1,10 +1,10 @@ import React from "react"; import { AnchorButton, IButtonProps, MaybeElement } from "@blueprintjs/core"; import styled, { css } from "styled-components"; -import { TextComponentProps } from "./TextComponent"; import { ButtonStyle } from "widgets/ButtonWidget"; import { Theme } from "constants/DefaultTheme"; import _ from "lodash"; +import { ComponentProps } from "components/designSystems/appsmith/BaseComponent"; const getButtonColorStyles = (props: { theme: Theme } & ButtonStyleProps) => { if (props.filled) return props.theme.colors.textOnDarkBG; @@ -98,7 +98,8 @@ BaseButton.defaultProps = { minimal: true, }; -interface ButtonContainerProps extends TextComponentProps { +interface ButtonContainerProps extends ComponentProps { + text?: string; icon?: MaybeElement; onClick?: (event: React.MouseEvent) => void; disabled?: boolean; diff --git a/app/client/src/components/designSystems/blueprint/TextComponent.tsx b/app/client/src/components/designSystems/blueprint/TextComponent.tsx index 76fad0fae8..d7c86469e6 100644 --- a/app/client/src/components/designSystems/blueprint/TextComponent.tsx +++ b/app/client/src/components/designSystems/blueprint/TextComponent.tsx @@ -15,6 +15,7 @@ export interface TextComponentProps extends ComponentProps { ellipsize?: boolean; textStyle?: TextStyle; isLoading: boolean; + allowHtml: boolean; } class TextComponent extends React.Component { @@ -37,14 +38,22 @@ class TextComponent extends React.Component { } render() { - return ( - - {this.props.text} - - ); + const { allowHtml, textStyle, text, ellipsize } = this.props; + if (allowHtml && text) { + const markup = { __html: text }; + return ( +
+ ); + } else { + return ( + + {text} + + ); + } } } diff --git a/app/client/src/components/designSystems/syncfusion/TableComponent.tsx b/app/client/src/components/designSystems/syncfusion/TableComponent.tsx index f726b3adc1..9545630dbb 100644 --- a/app/client/src/components/designSystems/syncfusion/TableComponent.tsx +++ b/app/client/src/components/designSystems/syncfusion/TableComponent.tsx @@ -6,6 +6,8 @@ import { Grid, Inject, Resize, + Page, + SelectionSettingsModel, } from "@syncfusion/ej2-react-grids"; import * as React from "react"; import styled from "constants/DefaultTheme"; @@ -25,38 +27,72 @@ const StyledGridComponent = styled(GridComponent)` background-color: #fafafa; } `; +const settings: SelectionSettingsModel = { + type: "Multiple", +}; export default class TableComponent extends React.Component< TableComponentProps, {} > { private grid: Grid | null | undefined; - public rowSelected = () => { + rowSelected = () => { if (this.grid) { /** Get the selected row indexes */ const selectedrowindex: number[] = this.grid.getSelectedRowIndexes(); /** Get the selected records. */ const selectedrecords: object[] = this.grid.getSelectedRecords(); - this.props.onRowClick(selectedrecords[0], selectedrowindex[0]); + if (selectedrecords.length !== 0) { + this.props.onRowClick(selectedrecords[0], selectedrowindex[0]); + } } }; - public dataBound = () => { + + reCalculatePageSize = () => { + if (this.grid) { + /** height of the each row */ + const rowHeight: number = this.grid.getRowHeight(); + /** Grid height */ + const gridHeight: number = this.grid.height as number; + /** initial page size */ + const pageSize: number = this.grid.pageSettings.pageSize as number; + /** new page size is obtained here */ + const pageResize: any = (gridHeight - pageSize * rowHeight) / rowHeight; + this.grid.pageSettings.pageSize = pageSize + Math.round(pageResize); + } + }; + dataBound = () => { if (this.grid) { this.grid.autoFitColumns(); } }; - public render() { + + shouldComponentUpdate(nextProps: TableComponentProps) { + const propsNotEqual = + JSON.stringify(nextProps.data) !== JSON.stringify(this.props.data) || + nextProps.height !== this.props.height || + nextProps.width !== this.props.width; + + return propsNotEqual; + } + componentDidUpdate(prevProps: TableComponentProps) { + if (prevProps.height !== this.props.height) { + this.reCalculatePageSize(); + } + } + render() { return ( (this.grid = g)} width={this.props.width - 16} - height={this.props.height - 62} + height={this.props.height - 107} dataBound={this.dataBound} + allowPaging={true} > - + {this.props.columns.map(col => { return ; diff --git a/app/client/src/components/editorComponents/ApiResponseView.tsx b/app/client/src/components/editorComponents/ApiResponseView.tsx index 7dea588b8f..b4a6a20b31 100644 --- a/app/client/src/components/editorComponents/ApiResponseView.tsx +++ b/app/client/src/components/editorComponents/ApiResponseView.tsx @@ -11,7 +11,7 @@ import { formatBytes } from "utils/helpers"; import { APIEditorRouteParams } from "constants/routes"; import { ApiPaneReduxState } from "reducers/uiReducers/apiPaneReducer"; import LoadingOverlayScreen from "components/editorComponents/LoadingOverlayScreen"; -import DynamicAutocompleteInput from "components/editorComponents/DynamicAutocompleteInput"; +import CodeEditor from "components/editorComponents/CodeEditor"; const ResponseWrapper = styled.div` position: relative; @@ -138,12 +138,13 @@ const ApiResponseView = (props: Props) => { key: "body", title: "Response Body", panelComponent: ( - ), }, diff --git a/app/client/src/components/editorComponents/CodeEditor.tsx b/app/client/src/components/editorComponents/CodeEditor.tsx index a5967da8f3..0fb14743a8 100644 --- a/app/client/src/components/editorComponents/CodeEditor.tsx +++ b/app/client/src/components/editorComponents/CodeEditor.tsx @@ -21,19 +21,19 @@ interface Props { class CodeEditor extends React.Component { textArea = React.createRef(); editor: any; - constructor(props: Props) { - super(props); - } componentDidMount(): void { if (this.textArea.current) { + const readOnly = !this.props.input.onChange; this.editor = cm.fromTextArea(this.textArea.current, { mode: { name: "javascript", json: true }, value: this.props.input.value, + readOnly, lineNumbers: true, tabSize: 2, indentWithTabs: true, lineWrapping: true, }); + this.editor.setSize(null, this.props.height); } } diff --git a/app/client/src/components/editorComponents/DynamicAutocompleteInput.tsx b/app/client/src/components/editorComponents/DynamicAutocompleteInput.tsx index cc8e29e842..f69ad7aab2 100644 --- a/app/client/src/components/editorComponents/DynamicAutocompleteInput.tsx +++ b/app/client/src/components/editorComponents/DynamicAutocompleteInput.tsx @@ -1,43 +1,146 @@ import React, { Component } from "react"; import { connect } from "react-redux"; import { AppState } from "reducers"; -import styled from "styled-components"; -import CodeMirror, { EditorConfiguration } from "codemirror"; +import styled, { createGlobalStyle } from "styled-components"; +import CodeMirror, { EditorConfiguration, LineHandle } from "codemirror"; import "codemirror/lib/codemirror.css"; import "codemirror/theme/monokai.css"; import "codemirror/addon/hint/show-hint"; -import "codemirror/addon/hint/show-hint.css"; import "codemirror/addon/hint/javascript-hint"; +import "codemirror/addon/display/placeholder"; import { - getNameBindingsWithData, + getNameBindingsForAutocomplete, NameBindingsWithData, } from "selectors/nameBindingsWithDataSelector"; +import { AUTOCOMPLETE_MATCH_REGEX } from "constants/BindingsConstants"; +import ErrorTooltip from "components/editorComponents/ErrorTooltip"; +import { WrappedFieldInputProps, WrappedFieldMetaProps } from "redux-form"; +import _ from "lodash"; +import { parseDynamicString } from "utils/DynamicBindingUtils"; require("codemirror/mode/javascript/javascript"); -const Wrapper = styled.div<{ height?: number; theme?: "LIGHT" | "DARK" }>` - border: ${props => props.theme !== "DARK" && "1px solid #d0d7dd"}; +const HintStyles = createGlobalStyle` + .CodeMirror-hints { + position: absolute; + z-index: 10; + overflow: hidden; + list-style: none; + margin: 0; + padding: 5px; + font-size: 90%; + font-family: monospace; + max-height: 20em; + width: 200px; + overflow-y: auto; + background: #FFFFFF; + border: 1px solid #EBEFF2; + box-shadow: 0px 2px 4px rgba(67, 70, 74, 0.14); + border-radius: 4px; + } + + .CodeMirror-hint { + height: 32px; + padding: 3px; + margin: 0; + white-space: pre; + color: #2E3D49; + cursor: pointer; + display: flex; + align-items: center; + font-size: 14px; + } + + li.CodeMirror-hint-active { + background: #E9FAF3; + border-radius: 4px; + } +`; + +const Wrapper = styled.div<{ + borderStyle?: THEME; + hasError: boolean; +}>` + border: 1px solid; + border-color: ${props => + props.hasError + ? props.theme.colors.error + : props.borderStyle !== THEMES.DARK + ? "#d0d7dd" + : "transparent"}; border-radius: 4px; display: flex; flex: 1; - flex-direction: column; + flex-direction: row; position: relative; text-transform: none; min-height: 32px; - height: ${props => (props.height ? `${props.height}px` : "32px")}; + overflow: hidden; + height: auto; + && { + .binding-highlight { + color: ${props => + props.borderStyle === THEMES.DARK ? "#f7c75b" : "#ffb100"}; + font-weight: 700; + } + .CodeMirror { + flex: 1; + line-height: 21px; + z-index: 0; + border-radius: 4px; + height: auto; + } + .CodeMirror pre.CodeMirror-placeholder { + color: #a3b3bf; + } + } `; +const IconContainer = styled.div` + .bp3-icon { + border-radius: 4px 0 0 4px; + margin: 0; + height: 32px; + width: 30px; + display: flex; + align-items: center; + justify-content: center; + background-color: #eef2f5; + svg { + height: 20px; + width: 20px; + path { + fill: #979797; + } + } + } +`; + +const THEMES = { + LIGHT: "LIGHT", + DARK: "DARK", +}; + +type THEME = "LIGHT" | "DARK"; + interface ReduxStateProps { dynamicData: NameBindingsWithData; } -type Props = ReduxStateProps & { - input: { - value: string; - onChange?: (value: string) => void; - }; - theme?: "LIGHT" | "DARK"; +export type DynamicAutocompleteInputProps = { + placeholder?: string; + leftIcon?: Function; + height?: number; + theme?: THEME; + meta?: Partial; + showLineNumbers?: boolean; + allowTabIndent?: boolean; }; +type Props = ReduxStateProps & + DynamicAutocompleteInputProps & { + input: Partial; + }; + class DynamicAutocompleteInput extends Component { textArea = React.createRef(); editor: any; @@ -47,31 +150,45 @@ class DynamicAutocompleteInput extends Component { const options: EditorConfiguration = {}; if (this.props.theme === "DARK") options.theme = "monokai"; if (!this.props.input.onChange) options.readOnly = true; + if (this.props.showLineNumbers) options.lineNumbers = true; + const extraKeys: Record = { + "Ctrl-Space": "autocomplete", + }; + if (!this.props.allowTabIndent) extraKeys["Tab"] = false; this.editor = CodeMirror.fromTextArea(this.textArea.current, { mode: { name: "javascript", globalVars: true }, + viewportMargin: 10, value: this.props.input.value, tabSize: 2, indentWithTabs: true, lineWrapping: true, - extraKeys: { "Ctrl-Space": "autocomplete" }, showHint: true, + extraKeys, ...options, }); - this.editor.on("change", this.handleChange); - this.editor.on("keyup", this.handleAutocompleteVisibility); + this.editor.on("change", _.debounce(this.handleChange, 200)); + this.editor.on("cursorActivity", this.handleAutocompleteVisibility); this.editor.setOption("hintOptions", { completeSingle: false, globalScope: this.props.dynamicData, }); + if (this.props.height) { + this.editor.setSize(0, this.props.height); + } + this.editor.eachLine(this.highlightBindings); } } componentDidUpdate(): void { if (this.editor) { const editorValue = this.editor.getValue(); - const inputValue = this.props.input.value; - if (inputValue && inputValue !== editorValue) { + let inputValue = this.props.input.value; + if (typeof inputValue === "object") { + inputValue = JSON.stringify(inputValue, null, 2); + } + if ((!!inputValue || inputValue === "") && inputValue !== editorValue) { this.editor.setValue(inputValue); + this.editor.setCursor(this.editor.lineCount(), 0); } } } @@ -81,27 +198,82 @@ class DynamicAutocompleteInput extends Component { if (this.props.input.onChange) { this.props.input.onChange(value); } + this.editor.eachLine(this.highlightBindings); }; - handleAutocompleteVisibility = (cm: any, event: any) => { - if (!cm.state.completionActive && event.keyCode !== 13) { + handleAutocompleteVisibility = (cm: any) => { + let cursorBetweenBinding = false; + const cursor = this.editor.getCursor(); + const value = this.editor.getValue(); + let cumulativeCharCount = 0; + parseDynamicString(value).forEach(segment => { + const start = cumulativeCharCount; + const dynamicStart = segment.indexOf("{{"); + const dynamicDoesStart = dynamicStart > -1; + const dynamicEnd = segment.indexOf("}}"); + const dynamicDoesEnd = dynamicEnd > -1; + const dynamicStartIndex = dynamicStart + start + 1; + const dynamicEndIndex = dynamicEnd + start + 1; + if ( + dynamicDoesStart && + cursor.ch > dynamicStartIndex && + ((dynamicDoesEnd && cursor.ch < dynamicEndIndex) || + (!dynamicDoesEnd && cursor.ch > dynamicStartIndex)) + ) { + cursorBetweenBinding = true; + } + cumulativeCharCount = start + segment.length; + }); + const shouldShow = cursorBetweenBinding && !cm.state.completionActive; + if (shouldShow) { cm.showHint(cm); } }; + highlightBindings = (line: LineHandle) => { + const lineNo = this.editor.getLineNumber(line); + let match; + while ((match = AUTOCOMPLETE_MATCH_REGEX.exec(line.text)) != null) { + const start = match.index; + const end = AUTOCOMPLETE_MATCH_REGEX.lastIndex; + this.editor.markText( + { ch: start, line: lineNo }, + { ch: end, line: lineNo }, + { + className: "binding-highlight", + }, + ); + } + }; + render() { - const { input, theme } = this.props; - const height = this.editor ? this.editor.doc.height + 20 : null; + const { input, meta, theme } = this.props; + const hasError = !!(meta && meta.error); + let showError = false; + if (this.editor) { + showError = hasError && this.editor.hasFocus(); + } return ( - -