Merge branch 'release' into buttonUITestCases

This commit is contained in:
arslanhaiderbuttar 2021-06-17 17:04:42 +05:00
commit 95382420df
68 changed files with 2021 additions and 199 deletions

View File

@ -83,7 +83,6 @@ describe("API Panel Test Functionality", function() {
// Publish the app
cy.PublishtheApp();
cy.wait("@postExecute");
// Assert on load data in table
cy.readTabledataPublish("0", "1").then((cellData) => {

View File

@ -13,6 +13,7 @@ import {
getApplicationViewerPageURL,
ORG_URL,
SIGN_UP_URL,
SIGNUP_SUCCESS_URL,
USER_AUTH_URL,
USERS_URL,
PROFILE,
@ -47,7 +48,8 @@ function changeAppBackground(currentTheme: any) {
if (
trimTrailingSlash(window.location.pathname) === "/applications" ||
window.location.pathname.indexOf("/settings/") !== -1 ||
trimTrailingSlash(window.location.pathname) === "/profile"
trimTrailingSlash(window.location.pathname) === "/profile" ||
trimTrailingSlash(window.location.pathname) === "/signup-success"
) {
document.body.style.backgroundColor =
currentTheme.colors.homepageBackground;
@ -101,6 +103,11 @@ class AppRouter extends React.Component<any, any> {
exact
path={APPLICATIONS_URL}
/>
<SentryRoute
component={ApplicationListLoader}
exact
path={SIGNUP_SUCCESS_URL}
/>
<SentryRoute component={EditorLoader} path={BUILDER_URL} />
<SentryRoute
component={AppViewerLoader}

View File

@ -10,10 +10,10 @@ import {
ERROR_CODES,
SERVER_ERROR_CODES,
} from "constants/ApiConstants";
import history from "utils/history";
import { AUTH_LOGIN_URL } from "constants/routes";
import log from "loglevel";
import { ActionExecutionResponse } from "api/ActionAPI";
import store from "store";
import { logoutUser } from "actions/userActions";
const executeActionRegex = /actions\/execute/;
const timeoutErrorRegex = /timeout of (\d+)ms exceeded/;
@ -100,10 +100,9 @@ export const apiFailureResponseInterceptor = (error: any) => {
const currentUrl = `${window.location.href}`;
if (error.response.status === API_STATUS_CODES.REQUEST_NOT_AUTHORISED) {
// Redirect to login and set a redirect url.
history.replace({
pathname: AUTH_LOGIN_URL,
search: `redirectUrl=${encodeURIComponent(currentUrl)}`,
});
store.dispatch(
logoutUser({ redirectURL: encodeURIComponent(currentUrl) }),
);
return Promise.reject({
code: ERROR_CODES.REQUEST_NOT_AUTHORISED,
message: "Unauthorized. Redirecting to login page...",

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.6101 7.33577C13.7584 5.80292 11.8038 5 10.0034 5C5.66552 5 2.25348 9.4708 2.11631 9.65328C1.96123 9.85496 1.96123 10.145 2.11631 10.3467C2.79556 11.2046 3.56016 11.9817 4.39672 12.6642C6.24848 14.1971 8.20311 15 10.0034 15C14.3413 15 17.7534 10.5292 17.8906 10.3285C18.0365 10.1302 18.0365 9.85155 17.8906 9.65328C17.2112 8.79548 16.4466 8.01845 15.6101 7.33577ZM10.0034 13.3507C8.07167 13.3507 6.50567 11.8507 6.50567 10.0004C6.50567 8.15001 8.07167 6.65 10.0034 6.65C11.9352 6.65 13.5012 8.15001 13.5012 10.0004C13.4918 11.847 11.9313 13.3417 10.0034 13.3507ZM8.10024 10.003C8.10024 8.99618 8.95233 8.18 10.0034 8.18C11.0545 8.18 11.9066 8.99618 11.9066 10.003C11.9066 11.0098 11.0545 11.826 10.0034 11.826C8.95233 11.826 8.10024 11.0098 8.10024 10.003Z" fill="#858282"/>
</svg>

After

Width:  |  Height:  |  Size: 930 B

View File

@ -10,10 +10,7 @@ import {
shouldShowResolved as shouldShowResolvedSelector,
appCommentsFilter as appCommentsFilterSelector,
} from "selectors/commentsSelectors";
import {
getCurrentApplicationId,
getCurrentPageId,
} from "selectors/editorSelectors";
import { getCurrentApplicationId } from "selectors/editorSelectors";
import CommentThread from "comments/CommentThread/connectedCommentThread";
import AppCommentsPlaceholder from "./AppCommentsPlaceholder";
@ -41,8 +38,6 @@ function AppCommentThreads() {
const currentUser = useSelector(getCurrentUser);
const currentUsername = currentUser?.username;
const currentPageId = useSelector(getCurrentPageId);
const commentThreadIds = useMemo(
() =>
getSortedAndFilteredAppCommentThreadIds(
@ -51,7 +46,6 @@ function AppCommentThreads() {
shouldShowResolved,
appCommentsFilter,
currentUsername,
currentPageId,
),
[
appCommentThreadIds,
@ -59,7 +53,6 @@ function AppCommentThreads() {
shouldShowResolved,
appCommentsFilter,
currentUsername,
currentPageId,
],
);

View File

@ -11,12 +11,12 @@ const Container = styled.div<{ isInline?: boolean }>`
position: fixed;
left: 0;
top: ${props.theme.smallHeaderHeight};
height: calc(100% - ${props.theme.smallHeaderHeight});
`
: `
position: unset;
position: relative;
`}
z-index: ${Layers.appComments};
height: calc(100% - ${(props) => props.theme.smallHeaderHeight});
display: flex;
flex-direction: column;
`;

View File

@ -29,7 +29,9 @@ import copy from "copy-to-clipboard";
import moment from "moment";
import history from "utils/history";
import UserApi from "api/UserApi";
import { getAppMode } from "selectors/applicationSelectors";
import { USER_PHOTO_URL } from "constants/userConstants";
import { getCommentThreadURL } from "../utils";
@ -49,10 +51,7 @@ import { createMessage, LINK_COPIED_SUCCESSFULLY } from "constants/messages";
import { Variant } from "components/ads/common";
import TourTooltipWrapper from "components/ads/tour/TourTooltipWrapper";
import { TourType } from "entities/Tour";
import {
getCurrentApplicationId,
getCurrentPageId,
} from "selectors/editorSelectors";
import { getCurrentApplicationId } from "selectors/editorSelectors";
const StyledContainer = styled.div`
width: 100%;
@ -270,18 +269,20 @@ function CommentCard({
const pinnedByUsername = commentThread.pinnedState?.authorUsername;
let pinnedBy = commentThread.pinnedState?.authorName;
const appMode = useSelector(getAppMode);
if (currentUserUsername === pinnedByUsername) {
pinnedBy = "You";
}
const pageId = useSelector(getCurrentPageId);
const applicationId = useSelector(getCurrentApplicationId);
const commentThreadURL = getCommentThreadURL({
applicationId,
commentThreadId,
isResolved: !!commentThread?.resolvedState?.active,
pageId,
pageId: commentThread?.pageId,
mode: appMode,
});
const copyCommentLink = () => {
@ -394,7 +395,7 @@ function CommentCard({
<HeaderSection>
<ProfileImage
side={25}
source={`/api/${UserApi.photoURL}/${authorUsername}`}
source={`/api/${USER_PHOTO_URL}/${authorUsername}`}
userName={authorName || ""}
/>
<UserName>{authorName}</UserName>

View File

@ -5,7 +5,7 @@ import { reduxForm } from "redux-form";
import FormGroup from "components/ads/formFields/FormGroup";
import FormTextField from "components/ads/formFields/TextField";
import FormDisplayImage from "./FormDisplayImage";
import UserProfileImagePicker from "components/ads/UserProfileImagePicker";
import { createMessage, DISPLAY_NAME, EMAIL_ADDRESS } from "constants/messages";
import styled from "styled-components";
@ -48,7 +48,7 @@ function ProfileForm(props: any) {
return (
<Container>
<div style={{ marginBottom: 10 }}>
<FormDisplayImage />
<UserProfileImagePicker />
</div>
<FormGroup label={createMessage(DISPLAY_NAME)}>
<FormTextField

View File

@ -116,7 +116,9 @@ const useUserSuggestions = (
setSuggestions: Dispatch<SetStateAction<Array<MentionData>>>,
) => {
useEffect(() => {
setSuggestions(users.map((user) => ({ name: user.username, user })));
setSuggestions(
users.map((user) => ({ name: user.name || user.username, user })),
);
}, [users]);
};
@ -153,8 +155,9 @@ function AddCommentInput({
const onSaveComment = useCallback(
(editorStateArg?: EditorState) => {
const latestEditorState = editorStateArg || editorState;
const plainText = latestEditorState.getCurrentContent().getPlainText();
if (!latestEditorState.getCurrentContent().hasText()) return;
if (!plainText || plainText.trim().length === 0) return;
const contentState = latestEditorState.getCurrentContent();
const rawContent = convertToRaw(contentState);

View File

@ -8,12 +8,14 @@ const useOrgUsers = () => {
const orgId = useSelector(getCurrentOrgId);
const orgUsers = useSelector(getAllUsers);
useEffect(() => {
dispatch({
type: ReduxActionTypes.FETCH_ALL_USERS_INIT,
payload: {
orgId,
},
});
if (!orgUsers || !orgUsers.length) {
dispatch({
type: ReduxActionTypes.FETCH_ALL_USERS_INIT,
payload: {
orgId,
},
});
}
}, [orgId]);
return orgUsers;
};

View File

@ -1,5 +1,9 @@
import { CommentThread } from "entities/Comments/CommentsInterfaces";
import { BUILDER_PAGE_URL } from "constants/routes";
import {
BUILDER_PAGE_URL,
getApplicationViewerPageURL,
} from "constants/routes";
import { APP_MODE } from "reducers/entityReducers/appReducer";
// used for dev
export const reduceCommentsByRef = (comments: any[]) => {
@ -85,11 +89,13 @@ export const getCommentThreadURL = ({
commentThreadId,
isResolved,
pageId,
mode = APP_MODE.PUBLISHED,
}: {
applicationId?: string;
commentThreadId: string;
isResolved?: boolean;
pageId?: string;
mode?: APP_MODE;
}) => {
const queryParams: Record<string, any> = {
commentThreadId,
@ -100,8 +106,13 @@ export const getCommentThreadURL = ({
queryParams.isResolved = true;
}
const urlBuilder =
mode === APP_MODE.PUBLISHED
? getApplicationViewerPageURL
: BUILDER_PAGE_URL;
const url = new URL(
`${window.location.origin}${BUILDER_PAGE_URL(
`${window.location.origin}${urlBuilder(
applicationId,
pageId,
queryParams,

View File

@ -8,6 +8,7 @@ import { getTypographyByKey } from "constants/DefaultTheme";
import styled from "styled-components";
import ImageEditor from "@uppy/image-editor";
import { REMOVE, createMessage } from "constants/messages";
import "@uppy/core/dist/style.css";
import "@uppy/dashboard/dist/style.css";
@ -16,6 +17,7 @@ import "@blueprintjs/popover2/lib/css/blueprint-popover2.css";
type Props = {
onChange: (file: File) => void;
onRemove?: () => void;
submit: (uppy: Uppy.Uppy) => void;
value: string;
label?: string;
@ -65,7 +67,12 @@ const Container = styled.div`
const defaultLabel = "Upload Display Picture";
export default function DisplayImageUpload({ onChange, submit, value }: Props) {
export default function DisplayImageUpload({
onChange,
onRemove,
submit,
value,
}: Props) {
const [loadError, setLoadError] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const uppy = useUppy(() => {
@ -144,6 +151,7 @@ export default function DisplayImageUpload({ onChange, submit, value }: Props) {
<Dialog
canEscapeKeyClose
canOutsideClickClose
className="file-picker-dialog"
isOpen={isModalOpen}
maxHeight={"80vh"}
trigger={
@ -164,6 +172,18 @@ export default function DisplayImageUpload({ onChange, submit, value }: Props) {
{(!value || loadError) && (
<span className="label">{defaultLabel}</span>
)}
{value && !loadError && (
<span
className="label"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onRemove) onRemove();
}}
>
{createMessage(REMOVE)}
</span>
)}
</div>
}
>

View File

@ -10,11 +10,11 @@ import "@draft-js-plugins/mention/lib/plugin.css";
import "draft-js/dist/Draft.css";
import { getTypographyByKey } from "constants/DefaultTheme";
import { EntryComponentProps } from "@draft-js-plugins/mention/lib/MentionSuggestions/Entry/Entry";
import UserApi from "api/UserApi";
import Icon from "components/ads/Icon";
import { INVITE_A_NEW_USER, createMessage } from "constants/messages";
import { USER_PHOTO_URL } from "constants/userConstants";
const StyledMention = styled.span`
color: ${(props) => props.theme.colors.comments.mention};
@ -103,11 +103,11 @@ function SuggestionComponent(props: EntryComponentProps) {
<StyledSuggestionsComponent {...parentProps}>
<ProfileImage
side={25}
source={`/api/${UserApi.photoURL}/${user?.username}`}
source={`/api/${USER_PHOTO_URL}/${user?.username}`}
userName={user?.username || ""}
/>
<div>
<Name>{user?.username}</Name>
<Name>{props.mention.name}</Name>
<Username>{user?.username}</Username>
</div>
</StyledSuggestionsComponent>

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import { updatePhoto } from "actions/userActions";
import { updatePhoto, removePhoto } from "actions/userActions";
import { useDispatch } from "react-redux";
import DisplayImageUpload from "components/ads/DisplayImageUpload";
@ -33,14 +33,18 @@ function FormDisplayImage() {
dispatchActionRef.current(uppy);
};
// TODO implement remove
// const removeProfileImage = () => {
// dispatch(removePhoto(() => {}));
// };
const removeProfileImage = () => {
dispatch(
removePhoto(() => {
setImageURL(`/api/${UserApi.photoURL}?${new Date().getTime()}`);
}),
);
};
return (
<DisplayImageUpload
onChange={onSelectFile}
onRemove={removeProfileImage}
submit={upload}
value={imageURL}
/>

View File

@ -163,13 +163,18 @@ function GlobalSearch() {
setQuery(resetSearchQuery);
} else {
dispatch(setGlobalSearchQuery(""));
if (!query) setActiveItemIndex(1);
if (!query)
recentEntities.length > 1
? setActiveItemIndex(2)
: setActiveItemIndex(1);
}
}, [modalOpen]);
useEffect(() => {
setActiveItemIndex(1);
}, [query]);
!query && recentEntities.length > 1
? setActiveItemIndex(2)
: setActiveItemIndex(1);
}, [query, recentEntities.length]);
const filteredWidgets = useMemo(() => {
if (!query) return searchableWidgets;

View File

@ -319,6 +319,7 @@ export const EMAIL_ADDRESS = () => "Email Address";
export const FIRST_AND_LAST_NAME = () => "First and last name";
export const MARK_ALL_AS_READ = () => "Mark all as read";
export const INVITE_A_NEW_USER = () => "Invite a new user";
export const REMOVE = () => "Remove";
// Showcase Carousel
export const NEXT = () => "NEXT";

View File

@ -175,6 +175,7 @@ export const BASE_SIGNUP_URL = `/signup`;
export const SIGN_UP_URL = `${USER_AUTH_URL}/signup`;
export const BASE_LOGIN_URL = `/login`;
export const AUTH_LOGIN_URL = `${USER_AUTH_URL}/login`;
export const SIGNUP_SUCCESS_URL = `/signup-success`;
export const ORG_INVITE_USERS_PAGE_URL = `${ORG_URL}/invite`;
export const ORG_SETTINGS_PAGE_URL = `${ORG_URL}/settings`;
@ -183,3 +184,4 @@ export const matchApiPath = match(API_EDITOR_ID_URL());
export const matchDatasourcePath = match(DATA_SOURCES_EDITOR_ID_URL());
export const matchQueryPath = match(QUERIES_EDITOR_ID_URL());
export const matchBuilderPath = match(BUILDER_URL);
export const matchViewerPath = match(getApplicationViewerPageURL());

View File

@ -32,3 +32,6 @@ export const DefaultCurrentUserDetails: User = {
gender: "MALE",
anonymousId: "anonymousId",
};
// TODO keeping it here instead of the USER_API since it leads to cyclic deps errors during tests
export const USER_PHOTO_URL = "v1/users/photo";

View File

@ -1,5 +1,6 @@
import { COMMENT_EVENTS } from "constants/CommentConstants";
import { RawDraftContentState } from "draft-js";
import { APP_MODE } from "reducers/entityReducers/appReducer";
// export enum CommentThreadParentTypes {
// widget = "widget",
@ -37,6 +38,7 @@ export type CreateCommentThreadRequest = {
};
};
isViewed?: boolean;
mode?: APP_MODE;
};
export type Reaction = {

View File

@ -1,5 +1,6 @@
import { createGlobalStyle } from "styled-components";
import { Layers } from "constants/Layers";
import { Classes } from "@blueprintjs/core";
export const PortalStyles = createGlobalStyle`
#header-root {
@ -17,4 +18,8 @@ export const PortalStyles = createGlobalStyle`
.bp3-portal {
z-index: ${Layers.portals};
}
.file-picker-dialog.bp3-dialog .${Classes.DIALOG_BODY} {
padding: 0;
}
`;

View File

@ -86,6 +86,7 @@ function CommentThreadNotification(props: {
const dispatch = useDispatch();
const { commentThread } = props.notification;
// TODO add isResolved, applicationId, pageId
// mode: commentThread?.mode
const commentThreadUrl = getCommentThreadURL({
commentThreadId: commentThread?.id,
});

View File

@ -27,7 +27,7 @@ import { fetchPublishedPage } from "actions/pageActions";
const Section = styled.section`
background: ${(props) => props.theme.colors.artboard};
height: 100%;
width: 100%;
margin: 0 auto;
position: relative;
overflow-x: auto;
overflow-y: auto;

View File

@ -0,0 +1,56 @@
import React from "react";
import { connect } from "react-redux";
import { Hotkey, Hotkeys } from "@blueprintjs/core";
import { HotkeysTarget } from "@blueprintjs/core/lib/esnext/components/hotkeys/hotkeysTarget.js";
import { setCommentMode as setCommentModeAction } from "actions/commentActions";
import { setCommentModeInUrl } from "pages/Editor/ToggleModeButton";
type Props = {
resetCommentMode: () => void;
children: React.ReactNode;
};
@HotkeysTarget
class GlobalHotKeys extends React.Component<Props> {
public renderHotkeys() {
return (
<Hotkeys>
<Hotkey
combo="esc"
global
group="Canvas"
label="Reset"
onKeyDown={(e: any) => {
this.props.resetCommentMode();
e.preventDefault();
}}
/>
<Hotkey
combo="v"
global
label="View Mode"
onKeyDown={this.props.resetCommentMode}
/>
<Hotkey
combo="c"
global
label="Comment Mode"
onKeyDown={() => setCommentModeInUrl(true)}
/>
</Hotkeys>
);
}
render() {
return <div>{this.props.children}</div>;
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
resetCommentMode: () => dispatch(setCommentModeAction(false)),
};
};
export default connect(null, mapDispatchToProps)(GlobalHotKeys);

View File

@ -1,5 +1,5 @@
import React, { Component } from "react";
import styled from "styled-components";
import styled, { ThemeProvider } from "styled-components";
import { connect } from "react-redux";
import { withRouter, RouteComponentProps, Route } from "react-router";
import { Switch } from "react-router-dom";
@ -30,6 +30,10 @@ import log from "loglevel";
import { getViewModePageList } from "selectors/editorSelectors";
import AppComments from "comments/AppComments/AppComments";
import AddCommentTourComponent from "comments/tour/AddCommentTourComponent";
import CommentShowCaseCarousel from "comments/CommentsShowcaseCarousel";
import { getThemeDetails, ThemeMode } from "selectors/themeSelectors";
import { Theme } from "constants/DefaultTheme";
import GlobalHotKeys from "./GlobalHotKeys";
const SentryRoute = Sentry.withSentryRouting(Route);
@ -47,9 +51,11 @@ const AppViewerBody = styled.section<{ hasPages: boolean }>`
const ContainerWithComments = styled.div`
display: flex;
width: 100%;
height: 100%;
`;
const AppViewerBodyContainer = styled.div<{ width?: string }>`
flex: 1;
overflow: auto;
margin: 0 auto;
`;
@ -71,6 +77,7 @@ export type AppViewerProps = {
) => void;
resetChildrenMetaProperty: (widgetId: string) => void;
pages: PageListPayload;
lightTheme: Theme;
} & RouteComponentProps<BuilderRouteParams>;
class AppViewer extends Component<
@ -98,36 +105,41 @@ class AppViewer extends Component<
public render() {
const { isInitialized } = this.props;
return (
<EditorContext.Provider
value={{
executeAction: this.props.executeAction,
updateWidgetMetaProperty: this.props.updateWidgetMetaProperty,
resetChildrenMetaProperty: this.props.resetChildrenMetaProperty,
}}
>
<ContainerWithComments>
<AppComments isInline />
<AppViewerBodyContainer>
<AppViewerBody hasPages={this.props.pages.length > 1}>
{isInitialized && this.state.registered && (
<Switch>
<SentryRoute
component={AppViewerPageContainer}
exact
path={getApplicationViewerPageURL()}
/>
<SentryRoute
component={AppViewerPageContainer}
exact
path={`${getApplicationViewerPageURL()}/fork`}
/>
</Switch>
)}
</AppViewerBody>
</AppViewerBodyContainer>
</ContainerWithComments>
<AddCommentTourComponent />
</EditorContext.Provider>
<ThemeProvider theme={this.props.lightTheme}>
<GlobalHotKeys>
<EditorContext.Provider
value={{
executeAction: this.props.executeAction,
updateWidgetMetaProperty: this.props.updateWidgetMetaProperty,
resetChildrenMetaProperty: this.props.resetChildrenMetaProperty,
}}
>
<ContainerWithComments>
<AppComments isInline />
<AppViewerBodyContainer>
<AppViewerBody hasPages={this.props.pages.length > 1}>
{isInitialized && this.state.registered && (
<Switch>
<SentryRoute
component={AppViewerPageContainer}
exact
path={getApplicationViewerPageURL()}
/>
<SentryRoute
component={AppViewerPageContainer}
exact
path={`${getApplicationViewerPageURL()}/fork`}
/>
</Switch>
)}
</AppViewerBody>
</AppViewerBodyContainer>
</ContainerWithComments>
<AddCommentTourComponent />
<CommentShowCaseCarousel />
</EditorContext.Provider>
</GlobalHotKeys>
</ThemeProvider>
);
}
}
@ -135,6 +147,7 @@ class AppViewer extends Component<
const mapStateToProps = (state: AppState) => ({
isInitialized: getIsInitialized(state),
pages: getViewModePageList(state),
lightTheme: getThemeDetails(state, ThemeMode.LIGHT),
});
const mapDispatchToProps = (dispatch: any) => ({

View File

@ -171,9 +171,14 @@ export function AppViewerHeader(props: AppViewerHeaderProps) {
<HtmlTitle />
<HeaderRow justify={"space-between"}>
<HeaderSection justify={"flex-start"}>
<PrimaryLogoLink to={APPLICATIONS_URL}>
<AppsmithLogoImg alt="Appsmith logo" src={AppsmithLogo} />
</PrimaryLogoLink>
<div style={{ flex: 1 }}>
<PrimaryLogoLink to={APPLICATIONS_URL}>
<AppsmithLogoImg alt="Appsmith logo" src={AppsmithLogo} />
</PrimaryLogoLink>
</div>
<div style={{ flex: 1 }}>
<ToggleCommentModeButton />
</div>
</HeaderSection>
<HeaderSection className="current-app-name" justify={"center"}>
{currentApplicationDetails && (
@ -181,7 +186,6 @@ export function AppViewerHeader(props: AppViewerHeaderProps) {
)}
</HeaderSection>
<HeaderSection justify={"flex-end"}>
<ToggleCommentModeButton />
{currentApplicationDetails && (
<>
<FormDialogComponent

View File

@ -78,9 +78,11 @@ import WelcomeHelper from "components/editorComponents/Onboarding/WelcomeHelper"
import { useIntiateOnboarding } from "components/editorComponents/Onboarding/utils";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { createOrganizationSubmitHandler } from "../organization/helpers";
import UserApi from "api/UserApi";
import ImportApplicationModal from "./ImportApplicationModal";
import OnboardingForm from "./OnboardingForm";
import { getAppsmithConfigs } from "configs";
import { SIGNUP_SUCCESS_URL } from "constants/routes";
const OrgDropDown = styled.div`
display: flex;
@ -769,6 +771,7 @@ function ApplicationsSection(props: any) {
<ProfileImage
className="org-share-user-icons"
key={el.username}
source={`/api/${UserApi.photoURL}/${el.username}`}
userName={el.name ? el.name : el.username}
/>
))}
@ -887,13 +890,9 @@ type ApplicationProps = {
};
const getIsFromSignup = () => {
if (window.location.href) {
const url = new URL(window.location.href);
const searchParams = url.searchParams;
return !!searchParams.get("isFromSignup");
}
return false;
return window.location?.pathname === SIGNUP_SUCCESS_URL;
};
const { onboardingFormEnabled } = getAppsmithConfigs();
class Applications extends Component<
ApplicationProps,
@ -912,9 +911,23 @@ class Applications extends Component<
PerformanceTracker.stopTracking(PerformanceTransactionName.LOGIN_CLICK);
PerformanceTracker.stopTracking(PerformanceTransactionName.SIGN_UP);
this.props.getAllApplication();
const isFromSignUp = getIsFromSignup();
this.setState({
showOnboardingForm: getIsFromSignup() && onboardingFormEnabled,
showOnboardingForm: isFromSignUp && onboardingFormEnabled,
});
// Redirect directly in case we're not showing the onboarding form
if (isFromSignUp && !onboardingFormEnabled) {
const urlObject = new URL(window.location.href);
const redirectUrl = urlObject?.searchParams.get("redirectUrl");
if (redirectUrl) {
try {
window.location.replace(redirectUrl);
} catch (e) {
console.error("Error handling the redirect url");
}
}
}
}
public render() {

View File

@ -95,6 +95,7 @@ const HeaderSection = styled.div`
align-items: center;
:nth-child(1) {
justify-content: flex-start;
max-width: 30%;
}
:nth-child(2) {
justify-content: center;

View File

@ -1,9 +1,10 @@
import React, { useCallback, useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import styled from "styled-components";
import { useDispatch, useSelector } from "react-redux";
import TooltipComponent from "components/ads/Tooltip";
import TourTooltipWrapper from "components/ads/tour/TourTooltipWrapper";
import { ReactComponent as Pen } from "assets/icons/comments/pen.svg";
import { ReactComponent as Eye } from "assets/icons/comments/eye.svg";
import { ReactComponent as CommentModeUnread } from "assets/icons/comments/comment-mode-unread-indicator.svg";
import { ReactComponent as CommentMode } from "assets/icons/comments/chat.svg";
import { Indices } from "constants/Layers";
@ -26,6 +27,10 @@ import { TourType } from "entities/Tour";
import useProceedToNextTourStep from "utils/hooks/useProceedToNextTourStep";
import { getCommentsIntroSeen } from "utils/storage";
import { User } from "constants/userConstants";
import { AppState } from "reducers";
import { APP_MODE } from "reducers/entityReducers/appReducer";
import { matchBuilderPath, matchViewerPath } from "constants/routes";
const ModeButton = styled.div<{ active: boolean }>`
position: relative;
@ -117,6 +122,40 @@ export const setCommentModeInUrl = (isCommentMode: boolean) => {
});
};
function EditModeReset() {
return (
<TooltipComponent
content={
<>
Edit Mode
<span style={{ color: "#fff", marginLeft: 20 }}>V</span>
</>
}
hoverOpenDelay={1000}
position={Position.BOTTOM}
>
<Pen />
</TooltipComponent>
);
}
function ViewModeReset() {
return (
<TooltipComponent
content={
<>
View Mode
<span style={{ color: "#fff", marginLeft: 20 }}>V</span>
</>
}
hoverOpenDelay={1000}
position={Position.BOTTOM}
>
<Eye />
</TooltipComponent>
);
}
function ToggleCommentModeButton() {
const commentsEnabled = useSelector(areCommentsEnabledForUserAndAppSelector);
const isCommentMode = useSelector(commentModeSelector);
@ -129,6 +168,18 @@ function ToggleCommentModeButton() {
0,
);
const mode = useSelector((state: AppState) => state.entities.app.mode);
// Show comment mode button only on the canvas editor and viewer
const [shouldHide, setShouldHide] = useState(false);
const location = useLocation();
useEffect(() => {
const pathName = window.location.pathname;
const shouldShow = matchBuilderPath(pathName) || matchViewerPath(pathName);
setShouldHide(!shouldShow);
}, [location]);
if (shouldHide) return null;
if (!commentsEnabled) return null;
const CommentModeIcon = showUnreadIndicator ? CommentModeUnread : CommentMode;
@ -168,18 +219,7 @@ function ToggleCommentModeButton() {
active={!isCommentMode}
onClick={() => setCommentModeInUrl(false)}
>
<TooltipComponent
content={
<>
Edit Mode
<span style={{ color: "#fff", marginLeft: 20 }}>V</span>
</>
}
hoverOpenDelay={1000}
position={Position.BOTTOM}
>
<Pen />
</TooltipComponent>
{mode === APP_MODE.EDIT ? <EditModeReset /> : <ViewModeReset />}
</ModeButton>
<ModeButton
active={isCommentMode}

View File

@ -12,6 +12,7 @@ import { Variant } from "components/ads/common";
import { FORGOT_PASSWORD_SUCCESS_TEXT } from "constants/messages";
import { logoutUser, updateUserDetails } from "actions/userActions";
import { AppState } from "reducers";
import UserProfileImagePicker from "components/ads/UserProfileImagePicker";
const Wrapper = styled.div`
& > div {
@ -23,12 +24,6 @@ const FieldWrapper = styled.div`
display: flex;
`;
const InputWrapper = styled.div`
width: 520px;
display: flex;
align-items: center;
`;
const LabelWrapper = styled.div`
width: 200px;
display: flex;
@ -90,21 +85,30 @@ function General() {
return (
<Wrapper>
<InputWrapper>
<FieldWrapper>
<LabelWrapper>
<Text type={TextType.H4}>Display Picture</Text>
</LabelWrapper>
<UserProfileImagePicker />
</FieldWrapper>
<FieldWrapper>
<LabelWrapper>
<Text type={TextType.H4}>Display name</Text>
</LabelWrapper>
{isFetchingUser && <Loader className={Classes.SKELETON} />}
{!isFetchingUser && (
<TextInput
cypressSelector="t--display-name"
defaultValue={user?.name}
onChange={onNameChange}
placeholder="Display name"
validator={notEmptyValidator}
/>
<div style={{ flex: 1 }}>
<TextInput
cypressSelector="t--display-name"
defaultValue={user?.name}
fill={false}
onChange={onNameChange}
placeholder="Display name"
validator={notEmptyValidator}
/>
</div>
)}
</InputWrapper>
</FieldWrapper>
<FieldWrapper>
<LabelWrapper>
<Text type={TextType.H4}>Email</Text>
@ -118,18 +122,7 @@ function General() {
</ForgotPassword>
</div>
</FieldWrapper>
{/* Commenting for now until the image related apis are ready */}
{/* <FieldWrapper>
<LabelWrapper>
<Text type={TextType.H4}>Display Picture</Text>
</LabelWrapper>
<FilePicker
url={""}
onFileRemoved={DeleteLogo}
logoUploadError={logoUploadError.message}
/>
</FieldWrapper>
<InputWrapper>
{/* <InputWrapper>
<LabelWrapper>
<Text type={TextType.H4}>Website</Text>
</LabelWrapper>

View File

@ -15,6 +15,7 @@ import { ReduxActionTypes } from "constants/ReduxActionConstants";
import ProfileImage from "./ProfileImage";
import { PopperModifiers } from "@blueprintjs/core";
import { PROFILE } from "constants/routes";
import UserApi from "api/UserApi";
type TagProps = CommonComponentProps & {
onClick?: (text: string) => void;
@ -76,7 +77,12 @@ const UserNameWrapper = styled.div`
`;
export default function ProfileDropdown(props: TagProps) {
const Profile = <ProfileImage userName={props.userName} />;
const Profile = (
<ProfileImage
source={`/api/${UserApi.photoURL}`}
userName={props.name || props.userName}
/>
);
return (
<>

View File

@ -24,6 +24,8 @@ import FilePicker, {
} from "components/ads/FilePicker";
import { getIsFetchingApplications } from "selectors/applicationSelectors";
// trigger tests
const InputLabelWrapper = styled.div`
width: 150px;
display: flex;

View File

@ -39,6 +39,7 @@ import { getInitialsAndColorCode } from "utils/AppsmithUtils";
import ProfileImage from "pages/common/ProfileImage";
import ManageUsers from "./ManageUsers";
import ScrollIndicator from "components/ads/ScrollIndicator";
import UserApi from "api/UserApi";
const OrgInviteTitle = styled.div`
padding: 10px 0px;
@ -343,7 +344,10 @@ function OrgInviteUsersForm(props: any) {
<Fragment key={user.username}>
<User>
<UserInfo>
<ProfileImage userName={user.initials} />
<ProfileImage
source={`/api/${UserApi.photoURL}/${user.username}`}
userName={user.name || user.username}
/>
<UserName>
<Text type={TextType.H5}>{user.name}</Text>
<Text type={TextType.P2}>{user.username}</Text>

View File

@ -40,6 +40,7 @@ import { getCurrentUser } from "selectors/usersSelectors";
import { get } from "lodash";
import { commentModeSelector } from "selectors/commentsSelectors";
import { AppState } from "reducers";
function* createUnpublishedCommentThread(
action: ReduxAction<Partial<CreateCommentThreadRequest>>,
@ -57,10 +58,12 @@ function* createCommentThread(action: ReduxAction<CreateCommentThreadPayload>) {
);
const applicationId = yield select(getCurrentApplicationId);
const pageId = yield select(getCurrentPageId);
const mode = yield select((state: AppState) => state.entities.app.mode);
const response = yield call(CommentsApi.createNewThread, {
...newCommentThreadPayload,
applicationId,
pageId,
mode,
});
const isValidResponse = yield validateResponse(response);

View File

@ -83,7 +83,6 @@ export const getSortedAndFilteredAppCommentThreadIds = (
shouldShowResolved: boolean,
appCommentsFilter: typeof filterOptions[number]["value"],
currentUserUsername?: string,
currentPageId?: string,
): Array<string> => {
if (!applicationThreadIds) return [];
const result = applicationThreadIds
@ -115,7 +114,6 @@ export const getSortedAndFilteredAppCommentThreadIds = (
// Happens during delete thread
if (!thread) return false;
if (thread?.pageId !== currentPageId) return false;
const isResolved = thread.resolvedState?.active;
const isPinned = thread.pinnedState?.active;

View File

@ -6,14 +6,13 @@ export enum ThemeMode {
DARK = "DARK",
}
const lightTheme = { ...theme, colors: { ...theme.colors, ...light } };
const darkTheme = { ...theme, colors: { ...theme.colors, ...dark } };
// Only for usage with ThemeProvider
export const getThemeDetails = (
state: AppState,
themeMode: ThemeMode,
): Theme => {
const colors = themeMode === ThemeMode.LIGHT ? light : dark;
return { ...theme, colors: { ...theme.colors, ...colors } };
};
export const getThemeDetails = (state: AppState, themeMode: ThemeMode): Theme =>
themeMode === ThemeMode.LIGHT ? lightTheme : darkTheme;
// Use to get the current theme of the app set via the theme switcher
export const getCurrentThemeDetails = (state: AppState): Theme =>

View File

@ -194,14 +194,14 @@ export const getInitialsAndColorCode = (
): string[] => {
let inits = "";
// if name contains space. eg: "Full Name"
if (fullName.includes(" ")) {
if (fullName && fullName.includes(" ")) {
const namesArr = fullName.split(" ");
let initials = namesArr.map((name: string) => name.charAt(0));
initials = initials.join("").toUpperCase();
inits = initials.slice(0, 2);
} else {
// handle for camelCase
const str = fullName.replace(/([a-z])([A-Z])/g, "$1 $2");
const str = fullName ? fullName.replace(/([a-z])([A-Z])/g, "$1 $2") : "";
const namesArr = str.split(" ");
let initials = namesArr.map((name: string) => name.charAt(0));
initials = initials.join("").toUpperCase();

View File

@ -33,8 +33,13 @@ export enum PerformanceTransactionName {
LOGIN_CLICK = "LOGIN_CLICK",
INIT_EDIT_APP = "INIT_EDIT_APP",
INIT_VIEW_APP = "INIT_VIEW_APP",
SHOW_RESIZE_HANDLES = "SHOW_RESIZE_HANDLES",
}
export type PerfTag = {
name: string;
value: string;
};
export interface PerfLog {
sentrySpan: Span;
skipLog?: boolean;
@ -51,6 +56,7 @@ class PerformanceTracker {
eventName: PerformanceTransactionName,
data?: any,
skipLog = false,
tags: Array<PerfTag> = [],
) => {
if (appsmithConfigs.sentry.enabled) {
const currentTransaction = Sentry.getCurrentHub()
@ -78,6 +84,11 @@ class PerformanceTracker {
);
}
const newTransaction = Sentry.startTransaction({ name: eventName });
tags.forEach(({ name: tagName, value }) => {
newTransaction.setTag(tagName, value);
});
newTransaction.setData("startData", data);
Sentry.getCurrentHub().configureScope((scope) =>
scope.setSpan(newTransaction),

View File

@ -27,5 +27,6 @@
<module>redshiftPlugin</module>
<module>amazons3Plugin</module>
<module>googleSheetsPlugin</module>
<module>snowflakePlugin</module>
</modules>
</project>

View File

@ -89,6 +89,8 @@ public class PostgresPlugin extends BasePlugin {
private static final String INTERVAL_TYPE_NAME = "interval";
private static final String JSON_TYPE_NAME = "json";
private static final String JSONB_TYPE_NAME = "jsonb";
private static final int MINIMUM_POOL_SIZE = 1;
@ -339,9 +341,9 @@ public class PostgresPlugin extends BasePlugin {
} else if (typeName.startsWith("_")) {
value = resultSet.getArray(i).getArray();
} else if (JSONB_TYPE_NAME.equalsIgnoreCase(typeName)) {
value = resultSet.getString(i);
} else if (JSON_TYPE_NAME.equalsIgnoreCase(typeName)
|| JSONB_TYPE_NAME.equalsIgnoreCase(typeName)) {
value = objectMapper.readTree(resultSet.getString(i));
} else {
value = resultSet.getObject(i);
}
@ -356,6 +358,11 @@ public class PostgresPlugin extends BasePlugin {
} catch (SQLException e) {
System.out.println(Thread.currentThread().getName() + ": In the PostgresPlugin, got action execution error");
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, e.getMessage()));
} catch (IOException e) {
// Since postgres json type field can only hold valid json data, this exception is not expected
// to occur.
System.out.println(Thread.currentThread().getName() + ": In the PostgresPlugin, got action execution error");
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, e.getMessage()));
} finally {
idleConnections = poolProxy.getIdleConnections();
activeConnections = poolProxy.getActiveConnections();

View File

@ -125,6 +125,12 @@ public class PostgresPluginTest {
" id timestamptz default now(),\n" +
" name timestamptz default now()\n" +
")");
statement.execute("CREATE TABLE jsontest (\n" +
" id serial PRIMARY KEY,\n" +
" item json,\n" +
" origin jsonb" +
")");
}
try (Statement statement = connection.createStatement()) {
@ -160,6 +166,14 @@ public class PostgresPluginTest {
")");
}
try (Statement statement = connection.createStatement()) {
statement.execute(
"INSERT INTO jsontest VALUES (" +
"1, '{\"type\":\"racket\", \"manufacturer\":\"butterfly\"}'," +
"'{\"country\":\"japan\", \"city\":\"kyoto\"}'"+
")");
}
} catch (SQLException throwable) {
throwable.printStackTrace();
}
@ -336,7 +350,7 @@ public class PostgresPluginTest {
StepVerifier.create(structureMono)
.assertNext(structure -> {
assertNotNull(structure);
assertEquals(3, structure.getTables().size());
assertEquals(4, structure.getTables().size());
final DatasourceStructure.Table campusTable = structure.getTables().get(0);
assertEquals("public.campus", campusTable.getName());
@ -350,7 +364,21 @@ public class PostgresPluginTest {
);
assertEquals(campusTable.getKeys().size(), 0);
final DatasourceStructure.Table possessionsTable = structure.getTables().get(1);
final DatasourceStructure.Table jsonTestTable = structure.getTables().get(1);
assertEquals("public.jsontest", jsonTestTable.getName());
assertEquals(DatasourceStructure.TableType.TABLE, campusTable.getType());
assertArrayEquals(
new DatasourceStructure.Column[]{
new DatasourceStructure.Column("id", "int4", "nextval('jsontest_id_seq" +
"'::regclass)"),
new DatasourceStructure.Column("item", "json", null),
new DatasourceStructure.Column("origin", "jsonb", null)
},
jsonTestTable.getColumns().toArray()
);
assertEquals(jsonTestTable.getKeys().size(), 1);
final DatasourceStructure.Table possessionsTable = structure.getTables().get(2);
assertEquals("public.possessions", possessionsTable.getName());
assertEquals(DatasourceStructure.TableType.TABLE, possessionsTable.getType());
assertArrayEquals(
@ -389,7 +417,7 @@ public class PostgresPluginTest {
possessionsTable.getTemplates().toArray()
);
final DatasourceStructure.Table usersTable = structure.getTables().get(2);
final DatasourceStructure.Table usersTable = structure.getTables().get(3);
assertEquals("public.users", usersTable.getName());
assertEquals(DatasourceStructure.TableType.TABLE, usersTable.getType());
assertArrayEquals(
@ -1063,4 +1091,28 @@ public class PostgresPluginTest {
})
.verifyComplete();
}
@Test
public void testJsonTypes() {
ActionConfiguration actionConfiguration = new ActionConfiguration();
actionConfiguration.setBody("SELECT * FROM jsontest");
DatasourceConfiguration dsConfig = createDatasourceConfiguration();
Mono<HikariDataSource> connectionPoolMono = pluginExecutor.datasourceCreate(dsConfig);
Mono<ActionExecutionResult> resultMono = connectionPoolMono
.flatMap(conn -> pluginExecutor.executeParameterized(conn, new ExecuteActionDTO(), dsConfig, actionConfiguration));
StepVerifier.create(resultMono)
.assertNext(result -> {
assertNotNull(result);
assertTrue(result.getIsExecutionSuccess());
assertNotNull(result.getBody());
final JsonNode node = ((ArrayNode) result.getBody()).get(0);
assertEquals("racket", node.get("item").get("type").asText());
assertEquals("butterfly", node.get("item").get("manufacturer").asText());
assertEquals("japan", node.get("origin").get("country").asText());
assertEquals("kyoto", node.get("origin").get("city").asText());
})
.verifyComplete();
}
}

View File

@ -0,0 +1,5 @@
plugin.id=snowflake-plugin
plugin.class=com.external.plugins.SnowflakePlugin
plugin.version=1.0-SNAPSHOT
plugin.provider=tech@appsmith.com
plugin.dependencies=

View File

@ -0,0 +1,236 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.external.plugins</groupId>
<artifactId>snowflakePlugin</artifactId>
<version>1.0-SNAPSHOT</version>
<name>snowflakePlugin</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>11</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<plugin.id>snowflake-plugin</plugin.id>
<plugin.class>com.external.plugins.SnowflakePlugin</plugin.class>
<plugin.version>1.0-SNAPSHOT</plugin.version>
<plugin.provider>tech@appsmith.com</plugin.provider>
<plugin.dependencies/>
</properties>
<dependencies>
<dependency>
<groupId>org.pf4j</groupId>
<artifactId>pf4j-spring</artifactId>
<version>0.7.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.appsmith</groupId>
<artifactId>interfaces</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.2.3.RELEASE</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.2.3.RELEASE</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
<version>5.2.3.RELEASE</version>
<exclusions>
<exclusion>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>net.snowflake</groupId>
<artifactId>snowflake-jdbc</artifactId>
<version>3.13.4</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.10.5.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
<version>2.10.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.10.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.2</version>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<version>3.3.5.RELEASE</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.1.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.13.2</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.powermock/powermock-module-junit4 -->
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-module-junit4</artifactId>
<version>2.0.9</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.powermock/powermock-api-mockito2 -->
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-api-mockito2</artifactId>
<version>2.0.9</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<version>2.2.4.RELEASE</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<configuration>
<minimizeJar>false</minimizeJar>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Plugin-Id>${plugin.id}</Plugin-Id>
<Plugin-Class>${plugin.class}</Plugin-Class>
<Plugin-Version>${plugin.version}</Plugin-Version>
<Plugin-Provider>${plugin.provider}</Plugin-Provider>
</manifestEntries>
</transformer>
</transformers>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<artifactItems>
<artifactItem>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</artifactItem>
<artifactItem>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
</artifactItem>
<artifactItem>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
</artifactItem>
</artifactItems>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,264 @@
package com.external.plugins;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.exceptions.pluginExceptions.StaleConnectionException;
import com.appsmith.external.models.ActionConfiguration;
import com.appsmith.external.models.ActionExecutionRequest;
import com.appsmith.external.models.ActionExecutionResult;
import com.appsmith.external.models.DBAuth;
import com.appsmith.external.models.DatasourceConfiguration;
import com.appsmith.external.models.DatasourceStructure;
import com.appsmith.external.models.DatasourceTestResult;
import com.appsmith.external.plugins.BasePlugin;
import com.appsmith.external.plugins.PluginExecutor;
import com.external.utils.SqlUtils;
import lombok.extern.slf4j.Slf4j;
import org.pf4j.Extension;
import org.pf4j.PluginWrapper;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
public class SnowflakePlugin extends BasePlugin {
public SnowflakePlugin(PluginWrapper wrapper) {
super(wrapper);
}
@Slf4j
@Extension
public static class SnowflakePluginExecutor implements PluginExecutor<Connection> {
private final Scheduler scheduler = Schedulers.elastic();
@Override
public Mono<ActionExecutionResult> execute(Connection connection, DatasourceConfiguration datasourceConfiguration, ActionConfiguration actionConfiguration) {
String query = actionConfiguration.getBody();
if (query == null) {
return Mono.error(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR,
"Missing required parameter: Query."));
}
return Mono
.fromCallable(() -> {
ResultSet resultSet = null;
List<Map<String, Object>> rowsList = new ArrayList<>(50);
try {
// We do not use keep alive threads for our connections since these might become expensive
// Instead for every execution, we check for connection validity,
// and reset the connection if required
if (!connection.isValid(30)) {
throw new StaleConnectionException();
}
Statement statement = connection.createStatement();
resultSet = statement.executeQuery(query);
ResultSetMetaData metaData = resultSet.getMetaData();
int colCount = metaData.getColumnCount();
while (resultSet.next()) {
// Use `LinkedHashMap` here so that the column ordering is preserved in the response.
Map<String, Object> row = new LinkedHashMap<>(colCount);
for (int i = 1; i <= colCount; i++) {
Object value = resultSet.getObject(i);
row.put(metaData.getColumnName(i), value);
}
rowsList.add(row);
}
} catch (SQLException e) {
e.printStackTrace();
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, e.getMessage());
} finally {
if (resultSet != null) {
try {
resultSet.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
return rowsList;
})
.map(rowsList -> {
ActionExecutionResult result = new ActionExecutionResult();
result.setBody(objectMapper.valueToTree(rowsList));
result.setIsExecutionSuccess(true);
ActionExecutionRequest request = new ActionExecutionRequest();
request.setQuery(query);
result.setRequest(request);
return result;
})
.subscribeOn(scheduler);
}
@Override
public Mono<Connection> datasourceCreate(DatasourceConfiguration datasourceConfiguration) {
try {
Class.forName("net.snowflake.client.jdbc.SnowflakeDriver");
} catch (ClassNotFoundException ex) {
System.err.println("Driver not found");
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, ex.getMessage()));
}
DBAuth authentication = (DBAuth) datasourceConfiguration.getAuthentication();
Properties properties = new Properties();
properties.setProperty("user", authentication.getUsername());
properties.setProperty("password", authentication.getPassword());
properties.setProperty("warehouse", String.valueOf(datasourceConfiguration.getProperties().get(0).getValue()));
properties.setProperty("db", String.valueOf(datasourceConfiguration.getProperties().get(1).getValue()));
return Mono
.fromCallable(() -> {
Connection conn;
try {
conn = DriverManager.getConnection("jdbc:snowflake://" + datasourceConfiguration.getUrl() + ".snowflakecomputing.com", properties);
} catch (SQLException e) {
e.printStackTrace();
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, e.getMessage());
}
if (conn == null) {
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Unable to create connection to Snowflake URL");
}
return conn;
})
.subscribeOn(scheduler);
}
@Override
public void datasourceDestroy(Connection connection) {
if (connection != null) {
try {
connection.close();
} catch (SQLException throwable) {
throwable.printStackTrace();
}
}
}
@Override
public Set<String> validateDatasource(DatasourceConfiguration datasourceConfiguration) {
Set<String> invalids = new HashSet<>();
if (StringUtils.isEmpty(datasourceConfiguration.getUrl())) {
invalids.add("Missing Snowflake URL.");
}
if (datasourceConfiguration.getProperties() != null
&& (datasourceConfiguration.getProperties().size() < 1
|| datasourceConfiguration.getProperties().get(0) == null
|| datasourceConfiguration.getProperties().get(0).getValue() == null
|| StringUtils.isEmpty(String.valueOf(datasourceConfiguration.getProperties().get(0).getValue())))) {
invalids.add("Missing warehouse name.");
}
if (datasourceConfiguration.getProperties() != null
&& (datasourceConfiguration.getProperties().size() < 2
|| datasourceConfiguration.getProperties().get(1) == null
|| datasourceConfiguration.getProperties().get(1).getValue() == null
|| StringUtils.isEmpty(String.valueOf(datasourceConfiguration.getProperties().get(1).getValue())))) {
invalids.add("Missing database name.");
}
if (datasourceConfiguration.getAuthentication() == null) {
invalids.add("Missing authentication details.");
} else {
DBAuth authentication = (DBAuth) datasourceConfiguration.getAuthentication();
if (StringUtils.isEmpty(authentication.getUsername())) {
invalids.add("Missing username for authentication.");
}
if (StringUtils.isEmpty(authentication.getPassword())) {
invalids.add("Missing password for authentication.");
}
}
return invalids;
}
@Override
public Mono<DatasourceTestResult> testDatasource(DatasourceConfiguration datasourceConfiguration) {
return datasourceCreate(datasourceConfiguration)
.flatMap(connection -> {
if (connection != null) {
try {
connection.close();
} catch (SQLException throwable) {
throwable.printStackTrace();
return Mono.error(throwable);
}
}
return Mono.just(new DatasourceTestResult());
})
.onErrorResume(error -> Mono.just(new DatasourceTestResult(error.getMessage())));
}
@Override
public Mono<DatasourceStructure> getStructure(Connection connection, DatasourceConfiguration datasourceConfiguration) {
final DatasourceStructure structure = new DatasourceStructure();
final Map<String, DatasourceStructure.Table> tablesByName = new LinkedHashMap<>();
final Map<String, DatasourceStructure.Key> keyRegistry = new HashMap<>();
return Mono
.fromSupplier(() -> {
try {
if (connection.isValid(30)) {
Statement statement = connection.createStatement();
final String columnsQuery = SqlUtils.COLUMNS_QUERY + "'"
+ datasourceConfiguration.getProperties().get(2).getValue() + "'";
ResultSet resultSet = statement.executeQuery(columnsQuery);
while (resultSet.next()) {
SqlUtils.getTableInfo(resultSet, tablesByName);
}
resultSet = statement.executeQuery(SqlUtils.PRIMARY_KEYS_QUERY);
while (resultSet.next()) {
SqlUtils.getPrimaryKeyInfo(resultSet, tablesByName, keyRegistry);
}
resultSet = statement.executeQuery(SqlUtils.FOREIGN_KEYS_QUERY);
while (resultSet.next()) {
SqlUtils.getForeignKeyInfo(resultSet, tablesByName, keyRegistry);
}
/* Get templates for each table and put those in. */
SqlUtils.getTemplates(tablesByName);
structure.setTables(new ArrayList<>(tablesByName.values()));
for (DatasourceStructure.Table table : structure.getTables()) {
table.getKeys().sort(Comparator.naturalOrder());
}
} else {
throw new StaleConnectionException();
}
} catch (SQLException throwable) {
throwable.printStackTrace();
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, throwable.getMessage());
}
return structure;
})
.subscribeOn(scheduler);
}
}
}

View File

@ -0,0 +1,235 @@
package com.external.utils;
import com.appsmith.external.models.DatasourceStructure;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class SqlUtils {
/**
* Example output for COLUMNS_QUERY:
* +------------+-----------+-------------+-------------+-------------+----------------+
* | TABLE_NAME | COLUMN_ID | COLUMN_NAME | COLUMN_TYPE | IS_NULLABLE | COLUMN_DEFAULT |
* +------------+-----------+-------------+-------------+-------------+----------------+
* | test | 1 | id | int | 0 | |
* | test | 2 | firstname | varchar | 1 | Foo |
* | test | 3 | middlename | varchar | 1 | |
* | test | 4 | lastname | varchar | 1 | |
* +------------+-----------+-------------+-------------+-------------+----------------+
*/
public static final String COLUMNS_QUERY =
"SELECT " +
"cols.table_name as table_name, " +
"cols.ordinal_position as column_id, " +
"cols.column_name as column_name, " +
"cols.data_type as column_type, " +
"cols.is_nullable = 'YES' as is_nullable, " +
"cols.column_default as column_default " +
"FROM " +
"information_schema.columns cols " +
"WHERE " +
"cols.table_schema = ";
/**
* Example output for PRIMARY_KEYS_QUERY:
* +------------+---------------+-------------+------------+-------------+--------------+--------------------+---------+
* | created_on | database_name | schema_name | table_name | column_name | key_sequence | constraint_name | comment |
* +------------+---------------+-------------+------------+-------------+--------------+--------------------+---------+
* | test | test_db | test_schema | test | id | 1 | SYS_CONSTRAINT_hex | |
* +------------+---------------+-------------+------------+-------------+--------------+--------------------+---------+
*/
public static final String PRIMARY_KEYS_QUERY = "SHOW PRIMARY KEYS";
/**
* Example output for FOREIGN_KEYS_QUERY:
* +------------+------------------+----------------+---------------+----------------+------------------+----------------+---------------+----------------+--------------+----------------+-------------+--------------------+--------------------+-----------------+------------+
* | created_on | pk_database_name | pk_schema_name | pk_table_name | pk_column_name | fk_database_name | fk_schema_name | fk_table_name | pk_column_name | key_sequence | update_rule | delete_rule | fk_name | pk_name | deferrability | comment |
* +------------+------------------+----------------+---------------+----------------+------------------+----------------+---------------+----------------+--------------+----------------+-------------+--------------------+--------------------+-----------------+------------+
* | test | test_db | test_schema | test | id | test_db | test_schema | test2 | f_id | 1 | NO ACTION | NO ACTION | SYS_CONSTRAINT_hex | SYS_CONSTRAINT_hex | | |
* +------------+------------------+----------------+---------------+----------------+------------------+----------------+---------------+----------------+--------------+----------------+-------------+--------------------+--------------------+-----------------+------------+
*/
public static final String FOREIGN_KEYS_QUERY = "SHOW IMPORTED KEYS";
public static String getDefaultValueByDataType(String datatype) {
if (datatype == null) {
return "null";
}
datatype = datatype.toUpperCase();
switch (datatype) {
case "NUMBER":
case "DECIMAL":
case "NUMERIC":
case "INTEGER":
case "INT":
case "BIGINT":
case "SMALLINT":
return "1";
case "FLOAT":
case "FLOAT4":
case "FLOAT8":
case "DOUBLE":
case "DOUBLE PRECISION":
case "REAL":
return "1.0";
case "BINARY":
case "VARBINARY":
return "to_binary('AB')";
case "BOOLEAN":
return "true";
case "DATE":
return "2021-01-01";
case "TIME":
return "00:00:01";
case "DATETIME":
case "TIMESTAMP":
case "TIMESTAMP_LTZ":
case "TIMESTAMP_NTZ":
case "TIMESTAMP_TZ":
return "2021-01-01 00:00:01";
case "ARRAY":
return "array_construct(1, 2, 3)";
case "VARIANT":
return
"parse_json(' { \"key1\": \"value1\", \"key2\": \"value2\" } ')";
case "OBJECT":
return
"parse_json(' { \"outer_key1\": { \"inner_key1A\": \"1a\", \"inner_key1B\": NULL }, '\n" +
" ||\n" +
" ' \"outer_key2\": { \"inner_key2\": 2 } '\n" +
" ||\n" +
" ' } ')";
case "GEOGRAPHY":
return "'POINT(-122.35 37.55)'";
case "VARCHAR":
case "CHAR":
case "CHARACTER":
case "STRING":
case "TEXT":
default:
return "''";
}
}
/**
* 1. Generate template for all tables in the database.
*/
public static void getTemplates(Map<String, DatasourceStructure.Table> tablesByName) {
for (DatasourceStructure.Table table : tablesByName.values()) {
final List<DatasourceStructure.Column> columnsWithoutDefault = table.getColumns()
.stream()
.filter(column -> column.getDefaultValue() == null)
.collect(Collectors.toList());
final List<String> columnNames = new ArrayList<>();
final List<String> columnValues = new ArrayList<>();
final StringBuilder setFragments = new StringBuilder();
for (DatasourceStructure.Column column : columnsWithoutDefault) {
final String name = column.getName();
final String type = column.getType();
String value = getDefaultValueByDataType(type);
columnNames.add(name);
columnValues.add(value);
setFragments.append("\n ").append(name).append(" = ").append(value);
}
final String tableName = table.getName();
table.getTemplates().addAll(List.of(
new DatasourceStructure.Template("SELECT", "SELECT * FROM " + tableName + " LIMIT 10;", null),
new DatasourceStructure.Template("INSERT", "INSERT INTO " + tableName
+ " (" + String.join(", ", columnNames) + ")\n"
+ " VALUES (" + String.join(", ", columnValues) + ");", null),
new DatasourceStructure.Template("UPDATE", "UPDATE " + tableName + " SET"
+ setFragments.toString() + "\n"
+ " WHERE 1 = 0; -- Specify a valid condition here. Removing the condition may update every row in the table!", null),
new DatasourceStructure.Template("DELETE", "DELETE FROM " + tableName
+ "\n WHERE 1 = 0; -- Specify a valid condition here. Removing the condition may delete everything in the table!", null)
));
}
}
public static void getForeignKeyInfo(ResultSet row, Map<String, DatasourceStructure.Table> tablesByName, Map<String, DatasourceStructure.Key> keyRegistry) throws SQLException {
final String constraintName = row.getString("fk_name");
final String selfSchema = row.getString("pk_schema_name");
final String fkTableName = row.getString("fk_table_name");
final String tableName = row.getString("pk_table_name");
if (!tablesByName.containsKey(tableName)) {
/* do nothing */
return;
}
final DatasourceStructure.Table table = tablesByName.get(tableName);
final String keyFullName = tableName + "." + constraintName;
final String foreignSchema = row.getString("fk_schema_name");
final String prefix = (foreignSchema.equalsIgnoreCase(selfSchema) ? "" : foreignSchema + ".")
+ fkTableName + ".";
if (!keyRegistry.containsKey(keyFullName)) {
final DatasourceStructure.ForeignKey key = new DatasourceStructure.ForeignKey(
constraintName,
new ArrayList<>(),
new ArrayList<>()
);
keyRegistry.put(keyFullName, key);
table.getKeys().add(key);
}
((DatasourceStructure.ForeignKey) keyRegistry.get(keyFullName)).getFromColumns()
.add(row.getString("pk_column_name"));
((DatasourceStructure.ForeignKey) keyRegistry.get(keyFullName)).getToColumns()
.add(prefix + row.getString("fk_column_name"));
}
public static void getPrimaryKeyInfo(ResultSet row, Map<String, DatasourceStructure.Table> tablesByName, Map<String, DatasourceStructure.Key> keyRegistry) throws SQLException {
final String constraintName = row.getString("constraint_name");
final String tableName = row.getString("table_name");
if (!tablesByName.containsKey(tableName)) {
/* do nothing */
return;
}
final DatasourceStructure.Table table = tablesByName.get(tableName);
final String keyFullName = tableName + "." + constraintName;
if (!keyRegistry.containsKey(keyFullName)) {
final DatasourceStructure.PrimaryKey key = new DatasourceStructure.PrimaryKey(
constraintName,
new ArrayList<>()
);
keyRegistry.put(keyFullName, key);
table.getKeys().add(key);
}
((DatasourceStructure.PrimaryKey) keyRegistry.get(keyFullName)).getColumnNames()
.add(row.getString("column_name"));
}
public static void getTableInfo(ResultSet row, Map<String, DatasourceStructure.Table> tablesByName) throws SQLException {
final String tableName = row.getString("TABLE_NAME");
if (!tablesByName.containsKey(tableName)) {
tablesByName.put(tableName, new DatasourceStructure.Table(
DatasourceStructure.TableType.TABLE,
tableName,
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>()
));
}
final DatasourceStructure.Table table = tablesByName.get(tableName);
table.getColumns().add(new DatasourceStructure.Column(
row.getString("COLUMN_NAME"),
row.getString("COLUMN_TYPE"),
row.getString("COLUMN_DEFAULT")
));
}
}

View File

@ -0,0 +1,16 @@
{
"editor": [
{
"sectionName": "",
"id": 1,
"children": [
{
"label": "",
"internalLabel": "Query",
"configProperty": "actionConfiguration.body",
"controlType": "QUERY_DYNAMIC_TEXT"
}
]
}
]
}

View File

@ -0,0 +1,69 @@
{
"form": [
{
"sectionName": "Connection",
"id": 1,
"children": [
{
"sectionName": null,
"children": [
{
"label": "Account name",
"configProperty": "datasourceConfiguration.url",
"controlType": "QUERY_DYNAMIC_INPUT_TEXT",
"isRequired": true,
"placeholderText": "xy12345.ap-south-1.aws"
},
{
"label": "Warehouse",
"configProperty": "datasourceConfiguration.properties[0].value",
"controlType": "QUERY_DYNAMIC_INPUT_TEXT",
"isRequired": true,
"placeholderText": "COMPUTE_WH"
},
{
"label": "Database",
"configProperty": "datasourceConfiguration.properties[1].value",
"controlType": "QUERY_DYNAMIC_INPUT_TEXT",
"isRequired": true,
"placeholderText": "SNOWFLAKE_SAMPLE_DATA"
},
{
"label": "Default Schema",
"configProperty": "datasourceConfiguration.properties[2].value",
"controlType": "QUERY_DYNAMIC_INPUT_TEXT",
"initialValue": "PUBLIC"
}
]
}
]
},
{
"sectionName": "Authentication",
"id": 2,
"children": [
{
"sectionName": null,
"children": [
{
"label": "Username",
"configProperty": "datasourceConfiguration.authentication.username",
"controlType": "INPUT_TEXT",
"placeholderText": "Username",
"isRequired": true
},
{
"label": "Password",
"configProperty": "datasourceConfiguration.authentication.password",
"dataType": "PASSWORD",
"controlType": "INPUT_TEXT",
"placeholderText": "Password",
"isRequired": true,
"encrypted": true
}
]
}
]
}
]
}

View File

@ -0,0 +1,35 @@
package com.external.plugins;
import com.appsmith.external.models.DBAuth;
import com.appsmith.external.models.DatasourceConfiguration;
import com.appsmith.external.models.Property;
import lombok.extern.log4j.Log4j;
import org.junit.Test;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.assertTrue;
@Log4j
public class SnowflakePluginTest {
SnowflakePlugin.SnowflakePluginExecutor pluginExecutor = new SnowflakePlugin.SnowflakePluginExecutor();
@Test
public void testValidateDatasource_InvalidCredentials_returnsInvalids() {
DatasourceConfiguration datasourceConfiguration = new DatasourceConfiguration();
DBAuth auth = new DBAuth();
auth.setUsername(null);
auth.setPassword(null);
datasourceConfiguration.setAuthentication(auth);
datasourceConfiguration.setProperties(List.of(new Property(), new Property()));
Set<String> output = pluginExecutor.validateDatasource(datasourceConfiguration);
assertTrue(output.contains("Missing username for authentication."));
assertTrue(output.contains("Missing password for authentication."));
assertTrue(output.contains("Missing Snowflake URL."));
assertTrue(output.contains("Missing warehouse name."));
assertTrue(output.contains("Missing database name."));
}
}

View File

@ -2,6 +2,7 @@ package com.appsmith.server.authentication.handlers;
import com.appsmith.server.constants.AnalyticsEvents;
import com.appsmith.server.constants.Security;
import com.appsmith.server.domains.User;
import com.appsmith.server.helpers.RedirectHelper;
import com.appsmith.server.services.AnalyticsService;
import com.appsmith.server.services.SessionUserService;
@ -21,8 +22,13 @@ import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
import static com.appsmith.server.helpers.RedirectHelper.SIGNUP_SUCCESS_URL;
@Slf4j
@Component
@RequiredArgsConstructor
@ -59,8 +65,16 @@ public class AuthenticationSuccessHandler implements ServerAuthenticationSuccess
) {
log.debug("Login succeeded for user: {}", authentication.getPrincipal());
if (authentication instanceof OAuth2AuthenticationToken) {
// In case of OAuth2 based authentication, there is no way to identify if this was a user signup (new user
// creation) or if this was a login (existing user). What we do here to identify this, is an approximation.
// If and when we find a better way to do identify this, let's please move away from this approximation.
// If the user object was created within the last 5 seconds, we treat it as a new user.
isFromSignup = ((User) authentication.getPrincipal()).getCreatedAt().isAfter(Instant.now().minusSeconds(5));
}
Mono<Void> redirectionMono = authentication instanceof OAuth2AuthenticationToken
? handleOAuth2Redirect(webFilterExchange)
? handleOAuth2Redirect(webFilterExchange, isFromSignup)
: handleRedirect(webFilterExchange, isFromSignup);
return sessionUserService.getCurrentUser()
@ -92,23 +106,26 @@ public class AuthenticationSuccessHandler implements ServerAuthenticationSuccess
// Disabling this because although the reference in the Javadoc is to a private method, it is still useful.
"JavadocReference"
)
private Mono<Void> handleOAuth2Redirect(WebFilterExchange webFilterExchange) {
private Mono<Void> handleOAuth2Redirect(WebFilterExchange webFilterExchange, boolean isFromSignup) {
ServerWebExchange exchange = webFilterExchange.getExchange();
String state = exchange.getRequest().getQueryParams().getFirst(Security.QUERY_PARAMETER_STATE);
String originHeader = RedirectHelper.DEFAULT_REDIRECT_URL;
String redirectUrl = RedirectHelper.DEFAULT_REDIRECT_URL;
String prefix = Security.STATE_PARAMETER_ORIGIN + "=";
if (state != null && !state.isEmpty()) {
String[] stateArray = state.split(",");
for (int i = 0; i < stateArray.length; i++) {
String stateVar = stateArray[i];
if (stateVar != null && stateVar.startsWith(Security.STATE_PARAMETER_ORIGIN) && stateVar.contains("=")) {
for (String stateVar : stateArray) {
if (stateVar != null && stateVar.startsWith(prefix)) {
// This is the origin of the request that we want to redirect to
originHeader = stateVar.split("=")[1];
redirectUrl = stateVar.split("=", 2)[1];
}
}
}
URI defaultRedirectLocation = URI.create(originHeader);
return this.redirectStrategy.sendRedirect(exchange, defaultRedirectLocation);
if (isFromSignup) {
redirectUrl = buildSignupSuccessUrl(redirectUrl);
}
return redirectStrategy.sendRedirect(exchange, URI.create(redirectUrl));
}
private Mono<Void> handleRedirect(WebFilterExchange webFilterExchange, boolean isFromSignup) {
@ -120,11 +137,17 @@ public class AuthenticationSuccessHandler implements ServerAuthenticationSuccess
.flatMap(redirectHelper::getRedirectUrl)
.map(url -> {
if (isFromSignup) {
url += (url.contains("?") ? "&" : "?") + "isFromSignup=true";
// This redirectUrl will be used by the client to redirect after showing a welcome page.
url = buildSignupSuccessUrl(url);
}
return url;
})
.map(URI::create)
.flatMap(redirectUri -> redirectStrategy.sendRedirect(exchange, redirectUri));
}
private String buildSignupSuccessUrl(String redirectUrl) {
return SIGNUP_SUCCESS_URL + "?redirectUrl=" + URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8);
}
}

View File

@ -2,20 +2,70 @@ package com.appsmith.server.controllers;
import com.appsmith.server.constants.Url;
import com.appsmith.server.domains.Notification;
import com.appsmith.server.dtos.ResponseDTO;
import com.appsmith.server.dtos.UpdateIsReadNotificationByIdDTO;
import com.appsmith.server.dtos.UpdateIsReadNotificationDTO;
import com.appsmith.server.exceptions.AppsmithException;
import com.appsmith.server.services.NotificationService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import javax.validation.Valid;
import static com.appsmith.server.exceptions.AppsmithError.UNSUPPORTED_OPERATION;
@Slf4j
@RestController
@RequestMapping(Url.NOTIFICATION_URL)
public class NotificationController extends BaseController<NotificationService, Notification, String> {
@Autowired
public NotificationController(NotificationService service) {
super(service);
}
@GetMapping("count/unread")
public Mono<ResponseDTO<Long>> getUnreadCount() {
return service.getUnreadCount()
.map(response -> new ResponseDTO<>(HttpStatus.OK.value(), response, null));
}
@PatchMapping("isRead")
public Mono<ResponseDTO<UpdateIsReadNotificationByIdDTO>> updateIsRead(
@RequestBody @Valid UpdateIsReadNotificationByIdDTO body) {
log.debug("Going to set isRead to notifications by id");
return service.updateIsRead(body).map(
dto -> new ResponseDTO<>(HttpStatus.OK.value(), dto, null, true)
);
}
@PatchMapping("isRead/all")
public Mono<ResponseDTO<UpdateIsReadNotificationDTO>> updateIsRead(
@RequestBody @Valid UpdateIsReadNotificationDTO body) {
log.debug("Going to set isRead to all notifications");
return service.updateIsRead(body).map(
dto -> new ResponseDTO<>(HttpStatus.OK.value(), dto, null, true)
);
}
@Override
public Mono<ResponseDTO<Notification>> create(Notification resource, String originHeader, ServerWebExchange exchange) {
return Mono.error(new AppsmithException(UNSUPPORTED_OPERATION));
}
@Override
public Mono<ResponseDTO<Notification>> update(String s, Notification resource) {
return Mono.error(new AppsmithException(UNSUPPORTED_OPERATION));
}
@Override
public Mono<ResponseDTO<Notification>> delete(String s) {
return Mono.error(new AppsmithException(UNSUPPORTED_OPERATION));
}
}

View File

@ -38,6 +38,10 @@ public class Comment extends BaseDomain {
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
String authorUsername;
private String applicationId;
private String applicationName;
private String pageId;
Body body;
List<Reaction> reactions;

View File

@ -2,6 +2,7 @@ package com.appsmith.server.domains;
import com.appsmith.external.models.BaseDomain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.data.annotation.Transient;
@ -35,9 +36,22 @@ public class CommentThread extends BaseDomain {
String applicationId;
String applicationName;
@JsonIgnore
Set<String> viewedByUsers;
String mode;
/**
* Display name of the user, who authored this comment thread.
*/
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
String authorName;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
String authorUsername;
@Transient
Boolean isViewed;

View File

@ -22,4 +22,12 @@ public class Notification extends BaseDomain {
return getClass().getSimpleName();
}
/**
* This method has been added because the createdAt property in base domain has @JsonIgnore annotation
* @return created time as a string
*/
public String getCreationTime() {
return this.createdAt.toString();
}
}

View File

@ -0,0 +1,19 @@
package com.appsmith.server.dtos;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
public class UpdateIsReadNotificationByIdDTO extends UpdateIsReadNotificationDTO {
@NotNull
@NotEmpty
private List<String> idList;
}

View File

@ -0,0 +1,15 @@
package com.appsmith.server.dtos;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotNull;
@Getter
@Setter
@EqualsAndHashCode
public class UpdateIsReadNotificationDTO {
@NotNull
private Boolean isRead;
}

View File

@ -0,0 +1,23 @@
package com.appsmith.server.helpers;
public class NumberUtils {
/**
* Parses an integer from a string. If the provided string is not an integer, it'll return the default value instead.
* If the parsed integer is less than minValue, it'll return the default value
* @param str
* @param minValue
* @param defaultValue
* @return parsed integer or default value depending on the conditions.
*/
public static int parseInteger(String str, int minValue, int defaultValue) {
try {
int i = Integer.parseInt(str);
if(i < minValue) {
return defaultValue;
}
return i;
} catch(Exception e) {
return defaultValue;
}
}
}

View File

@ -21,6 +21,7 @@ import static com.appsmith.server.acl.AclPermission.READ_APPLICATIONS;
public class RedirectHelper {
public static final String DEFAULT_REDIRECT_URL = "/applications";
public static final String SIGNUP_SUCCESS_URL = "/signup-success";
public static final String DEFAULT_REDIRECT_ORIGIN = "https://app.appsmith.com";
private static final String REDIRECT_URL_HEADER = "X-Redirect-Url";
private static final String REDIRECT_URL_QUERY_PARAM = "redirectUrl";

View File

@ -2392,16 +2392,16 @@ public class DatabaseChangelog {
@ChangeSet(order = "071", id = "add-application-export-permissions", author = "")
public void addApplicationExportPermissions(MongoTemplate mongoTemplate) {
final List<Organization> organizations = mongoTemplate.find(
query(where("userRoles").exists(true)),
Organization.class
query(where("userRoles").exists(true)),
Organization.class
);
for (final Organization organization : organizations) {
Set<String> adminUsernames = organization.getUserRoles()
.stream()
.filter(role -> (role.getRole().equals(AppsmithRole.ORGANIZATION_ADMIN)))
.map(role -> role.getUsername())
.collect(Collectors.toSet());
.stream()
.filter(role -> (role.getRole().equals(AppsmithRole.ORGANIZATION_ADMIN)))
.map(role -> role.getUsername())
.collect(Collectors.toSet());
if (adminUsernames.isEmpty()) {
continue;
@ -2416,7 +2416,7 @@ public class DatabaseChangelog {
}
Optional<Policy> exportAppOrgLevelOptional = policies.stream()
.filter(policy -> policy.getPermission().equals(ORGANIZATION_EXPORT_APPLICATIONS.getValue())).findFirst();
.filter(policy -> policy.getPermission().equals(ORGANIZATION_EXPORT_APPLICATIONS.getValue())).findFirst();
if (exportAppOrgLevelOptional.isPresent()) {
Policy exportApplicationPolicy = exportAppOrgLevelOptional.get();
@ -2424,7 +2424,7 @@ public class DatabaseChangelog {
} else {
// this policy doesnt exist. create and add this to the policy set
Policy inviteUserPolicy = Policy.builder().permission(ORGANIZATION_EXPORT_APPLICATIONS.getValue())
.users(exportApplicationPermissionUsernames).build();
.users(exportApplicationPermissionUsernames).build();
organization.getPolicies().add(inviteUserPolicy);
}
@ -2432,8 +2432,8 @@ public class DatabaseChangelog {
// Update the applications with export applications policy for all administrators of the organization
List<Application> orgApplications = mongoTemplate.find(
query(where(fieldName(QApplication.application.organizationId)).is(organization.getId())),
Application.class
query(where(fieldName(QApplication.application.organizationId)).is(organization.getId())),
Application.class
);
for (final Application application : orgApplications) {
@ -2443,7 +2443,7 @@ public class DatabaseChangelog {
}
Optional<Policy> exportAppOptional = applicationPolicies.stream()
.filter(policy -> policy.getPermission().equals(EXPORT_APPLICATIONS.getValue())).findFirst();
.filter(policy -> policy.getPermission().equals(EXPORT_APPLICATIONS.getValue())).findFirst();
if (exportAppOptional.isPresent()) {
Policy exportAppPolicy = exportAppOptional.get();
@ -2451,7 +2451,7 @@ public class DatabaseChangelog {
} else {
// this policy doesn't exist, create and add this to the policy set
Policy newExportAppPolicy = Policy.builder().permission(EXPORT_APPLICATIONS.getValue())
.users(adminUsernames).build();
.users(adminUsernames).build();
application.getPolicies().add(newExportAppPolicy);
}
@ -2459,4 +2459,26 @@ public class DatabaseChangelog {
}
}
}
@ChangeSet(order = "072", id = "add-snowflake-plugin", author = "")
public void addSnowflakePlugin(MongoTemplate mongoTemplate) {
Plugin plugin = new Plugin();
plugin.setName("Snowflake");
plugin.setType(PluginType.DB);
plugin.setPackageName("snowflake-plugin");
plugin.setUiComponent("DbEditorForm");
plugin.setDatasourceComponent("AutoForm");
plugin.setResponseType(Plugin.ResponseType.TABLE);
plugin.setIconLocation("https://s3.us-east-2.amazonaws.com/assets.appsmith.com/Snowflake.png");
plugin.setDocumentationLink("https://docs.appsmith.com/datasource-reference/querying-snowflake-db");
plugin.setDefaultInstall(true);
try {
mongoTemplate.insert(plugin);
} catch (DuplicateKeyException e) {
log.warn(plugin.getPackageName() + " already present in database.");
}
installPluginToAllOrganizations(mongoTemplate, plugin.getId());
}
}

View File

@ -1,6 +1,12 @@
package com.appsmith.server.repositories;
import com.appsmith.server.domains.Notification;
import com.mongodb.client.result.UpdateResult;
import reactor.core.publisher.Mono;
import java.util.List;
public interface CustomNotificationRepository extends AppsmithRepository<Notification> {
Mono<UpdateResult> updateIsReadByForUsernameAndIdList(String forUsername, List<String> idList, boolean isRead);
Mono<UpdateResult> updateIsReadByForUsername(String forUsername, boolean isRead);
}

View File

@ -1,13 +1,42 @@
package com.appsmith.server.repositories;
import com.appsmith.server.domains.Notification;
import com.appsmith.server.domains.QNotification;
import com.mongodb.client.result.UpdateResult;
import org.springframework.data.mongodb.core.ReactiveMongoOperations;
import org.springframework.data.mongodb.core.convert.MongoConverter;
import org.springframework.data.mongodb.core.query.Update;
import reactor.core.publisher.Mono;
public class CustomNotificationRepositoryImpl extends BaseAppsmithRepositoryImpl<Notification> implements CustomNotificationRepository {
import java.util.List;
import static org.springframework.data.mongodb.core.query.Criteria.where;
import static org.springframework.data.mongodb.core.query.Query.query;
public class CustomNotificationRepositoryImpl extends BaseAppsmithRepositoryImpl<Notification>
implements CustomNotificationRepository {
public CustomNotificationRepositoryImpl(ReactiveMongoOperations mongoOperations, MongoConverter mongoConverter) {
super(mongoOperations, mongoConverter);
}
@Override
public Mono<UpdateResult> updateIsReadByForUsernameAndIdList(String forUsername, List<String> idList, boolean isRead) {
return mongoOperations.updateMulti(
query(where(fieldName(QNotification.notification.forUsername)).is(forUsername)
.and(fieldName(QNotification.notification.id)).in(idList)
),
new Update().set(fieldName(QNotification.notification.isRead), isRead),
Notification.class
);
}
@Override
public Mono<UpdateResult> updateIsReadByForUsername(String forUsername, boolean isRead) {
return mongoOperations.updateMulti(
query(where(fieldName(QNotification.notification.forUsername)).is(forUsername)),
new Update().set(fieldName(QNotification.notification.isRead), isRead),
Notification.class
);
}
}

View File

@ -1,12 +1,17 @@
package com.appsmith.server.repositories;
import com.appsmith.server.domains.Notification;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Instant;
@Repository
public interface NotificationRepository extends BaseRepository<Notification, String>, CustomNotificationRepository {
Flux<Notification> findByForUsername(String userId);
Flux<Notification> findByForUsername(String userId, Pageable pageable);
Flux<Notification> findByForUsernameAndCreatedAtBefore(String userId, Instant instant, Pageable pageable);
Mono<Long> countByForUsername(String userId);
Mono<Long> countByForUsernameAndIsReadIsTrue(String userId);
}

View File

@ -6,13 +6,9 @@ import com.appsmith.server.acl.PolicyGenerator;
import com.appsmith.server.constants.FieldName;
import com.appsmith.server.domains.Application;
import com.appsmith.server.domains.Comment;
import com.appsmith.server.domains.CommentNotification;
import com.appsmith.server.domains.CommentThread;
import com.appsmith.server.domains.CommentThreadNotification;
import com.appsmith.server.domains.Notification;
import com.appsmith.server.domains.User;
import com.appsmith.server.events.CommentAddedEvent;
import com.appsmith.server.events.CommentThreadClosedEvent;
import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.exceptions.AppsmithException;
import com.appsmith.server.helpers.PolicyUtils;
@ -113,6 +109,9 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
comment.setAuthorId(user.getId());
comment.setThreadId(threadId);
comment.setApplicationId(thread.getApplicationId());
comment.setApplicationName(thread.getApplicationName());
comment.setPageId(thread.getPageId());
final Set<Policy> policies = policyGenerator.getAllChildPolicies(
thread.getPolicies(),
@ -148,10 +147,9 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
List<Mono<Notification>> notificationMonos = new ArrayList<>();
for (String username : usernames) {
if (!username.equals(user.getUsername())) {
final CommentNotification notification = new CommentNotification();
notification.setComment(savedComment);
notification.setForUsername(username);
Mono<Notification> notificationMono = notificationService.create(notification);
Mono<Notification> notificationMono = notificationService.createNotification(
savedComment, username
);
notificationMonos.add(notificationMono);
}
}
@ -194,6 +192,9 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
.flatMap(tuple -> {
final User user = tuple.getT1();
final Application application = tuple.getT2();
commentThread.setApplicationName(application.getName());
commentThread.setAuthorName(user.getName());
commentThread.setAuthorUsername(user.getUsername());
final Set<Policy> policies = policyGenerator.getAllChildPolicies(
application.getPolicies(),
@ -244,10 +245,7 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
List<Mono<Notification>> monos = new ArrayList<>();
for (String username : usernames) {
if (!username.equals(user.getUsername())) {
final CommentThreadNotification notification = new CommentThreadNotification();
notification.setCommentThread(commentThread);
notification.setForUsername(username);
monos.add(notificationService.create(notification));
monos.add(notificationService.createNotification(commentThread, username, user));
}
}
return Flux.concat(monos).then(Mono.just(commentThread));

View File

@ -1,13 +1,17 @@
package com.appsmith.server.services;
import com.appsmith.server.domains.Application;
import com.appsmith.server.domains.Comment;
import com.appsmith.server.domains.CommentThread;
import com.appsmith.server.domains.Notification;
import com.appsmith.server.domains.Organization;
import com.appsmith.server.domains.User;
import com.appsmith.server.dtos.UpdateIsReadNotificationByIdDTO;
import com.appsmith.server.dtos.UpdateIsReadNotificationDTO;
import reactor.core.publisher.Mono;
import java.util.List;
public interface NotificationService extends CrudService<Notification, String> {
Mono<Notification> createNotification(Comment comment, String forUsername);
Mono<Notification> createNotification(CommentThread commentThread, String forUsername, User authorUser);
Mono<UpdateIsReadNotificationByIdDTO> updateIsRead(UpdateIsReadNotificationByIdDTO dto);
Mono<UpdateIsReadNotificationDTO> updateIsRead(UpdateIsReadNotificationDTO dto);
Mono<Long> getUnreadCount();
}

View File

@ -1,9 +1,22 @@
package com.appsmith.server.services;
import com.appsmith.server.domains.Comment;
import com.appsmith.server.domains.CommentNotification;
import com.appsmith.server.domains.CommentThread;
import com.appsmith.server.domains.CommentThreadNotification;
import com.appsmith.server.domains.Notification;
import com.appsmith.server.domains.QNotification;
import com.appsmith.server.domains.User;
import com.appsmith.server.dtos.UpdateIsReadNotificationByIdDTO;
import com.appsmith.server.dtos.UpdateIsReadNotificationDTO;
import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.exceptions.AppsmithException;
import com.appsmith.server.helpers.NumberUtils;
import com.appsmith.server.repositories.NotificationRepository;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.convert.MongoConverter;
import org.springframework.stereotype.Service;
@ -13,6 +26,8 @@ import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import javax.validation.Validator;
import java.time.Instant;
import java.time.format.DateTimeParseException;
@Slf4j
@Service
@ -53,7 +68,85 @@ public class NotificationServiceImpl
@Override
public Flux<Notification> get(MultiValueMap<String, String> params) {
// results will be sorted in descending order of createdAt
Sort sort = Sort.by(Sort.Direction.DESC, QNotification.notification.createdAt.getMetadata().getName());
// get page size from query params, default is 10 if param not present
int pageSize = 10;
if(params.containsKey("pageSize")) {
String param = params.get("pageSize").get(0);
pageSize = NumberUtils.parseInteger(param, 1, 10);
}
PageRequest pageRequest = PageRequest.of(0, pageSize, sort);
// get the beforeDate parameter from query param
final Instant instant;
String paramKey = "beforeDate";
if(params.containsKey(paramKey)) {
String beforeParam = params.get(paramKey).get(0);
try {
instant = Instant.parse(beforeParam);
} catch (DateTimeParseException e) {
return Flux.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "as " + paramKey));
}
} else {
instant = Instant.now(); // param not present, use current time
}
return sessionUserService.getCurrentUser()
.flatMapMany(user -> repository.findByForUsername(user.getUsername()));
.flatMapMany(
user -> repository.findByForUsernameAndCreatedAtBefore(
user.getUsername(), instant, pageRequest
)
);
}
/**
* Creates a notification for the provided comment which is under provided comment thread
* @param comment
* @param forUsername
* @return
*/
@Override
public Mono<Notification> createNotification(Comment comment, String forUsername) {
final CommentNotification notification = new CommentNotification();
notification.setComment(comment);
notification.setForUsername(forUsername);
notification.setIsRead(false);
return repository.save(notification);
}
@Override
public Mono<Notification> createNotification(CommentThread commentThread, String forUsername, User authorUser) {
final CommentThreadNotification notification = new CommentThreadNotification();
notification.setCommentThread(commentThread);
notification.setForUsername(forUsername);
notification.setIsRead(false);
return repository.save(notification);
}
@Override
public Mono<UpdateIsReadNotificationByIdDTO> updateIsRead(UpdateIsReadNotificationByIdDTO dto) {
return sessionUserService.getCurrentUser()
.flatMap(user ->
repository.updateIsReadByForUsernameAndIdList(
user.getUsername(), dto.getIdList(), dto.getIsRead()
).thenReturn(dto)
);
}
@Override
public Mono<UpdateIsReadNotificationDTO> updateIsRead(UpdateIsReadNotificationDTO dto) {
return sessionUserService.getCurrentUser()
.flatMap(user -> repository.updateIsReadByForUsername(user.getUsername(), dto.getIsRead())
.thenReturn(dto)
);
}
@Override
public Mono<Long> getUnreadCount() {
return sessionUserService.getCurrentUser().flatMap(user ->
repository.countByForUsernameAndIsReadIsTrue(user.getUsername())
);
}
}

View File

@ -0,0 +1,16 @@
package com.appsmith.server.helpers;
import org.junit.Assert;
import org.junit.Test;
public class NumberUtilsTest {
@Test
public void parseInteger() {
Assert.assertEquals(2, NumberUtils.parseInteger("2", 0, 0));
Assert.assertEquals(2, NumberUtils.parseInteger("2", 2, 0));
Assert.assertEquals(10, NumberUtils.parseInteger("-2", 0, 10));
Assert.assertEquals(123, NumberUtils.parseInteger("abc", 0, 123));
Assert.assertEquals(123, NumberUtils.parseInteger("234.44", 0, 123));
}
}

View File

@ -0,0 +1,164 @@
package com.appsmith.server.repositories;
import com.appsmith.server.domains.Notification;
import lombok.extern.slf4j.Slf4j;
import org.junit.After;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import reactor.util.function.Tuple2;
import reactor.util.function.Tuple3;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class CustomNotificationRepositoryImplTest {
@Autowired
private NotificationRepository notificationRepository;
@After
public void afterTest() {
notificationRepository.deleteAll();
}
private Notification createNotification(String forUsername, boolean isRead) {
Notification notification = new Notification();
notification.setForUsername(forUsername);
notification.setIsRead(isRead);
return notification;
}
@Test
public void updateIsReadByForUsernameAndIdList_WhenUsernameNotMatched_UpdatesNone() {
Mono<Notification> saveMono1 = notificationRepository.save(createNotification("abc", false));
Mono<Notification> saveMono2 = notificationRepository.save(createNotification("efg", false));
// create the notifications and then try to update them by different username
Mono<Tuple2<Notification, Notification>> tuple2Mono = Mono.zip(saveMono1, saveMono2).flatMap(objects -> {
Notification n1 = objects.getT1();
Notification n2 = objects.getT2();
return notificationRepository.updateIsReadByForUsernameAndIdList(
"123", List.of(n1.getId(), n2.getId()), true
).thenReturn(objects);
});
// now get the notifications we created
Mono<List<Notification>> listMono = tuple2Mono.flatMap(objects -> {
Notification n1 = objects.getT1();
Notification n2 = objects.getT2();
return notificationRepository.findAllById(List.of(n1.getId(), n2.getId())).collectList();
});
// check that fetched notifications have isRead=false
StepVerifier.create(listMono).assertNext(notifications -> {
Assert.assertEquals(2, notifications.size());
Assert.assertEquals(false, notifications.get(0).getIsRead());
Assert.assertEquals(false, notifications.get(1).getIsRead());
}).verifyComplete();
}
@Test
public void updateIsReadByForUsernameAndIdList_WhenUsernameMatched_Updated() {
Mono<Notification> saveMono1 = notificationRepository.save(createNotification("abc", false));
Mono<Notification> saveMono2 = notificationRepository.save(createNotification("abc", false));
// create the notifications and then try to update them by same username
Mono<Tuple2<Notification, Notification>> tuple2Mono = Mono.zip(saveMono1, saveMono2).flatMap(objects -> {
Notification n1 = objects.getT1();
Notification n2 = objects.getT2();
return notificationRepository.updateIsReadByForUsernameAndIdList(
"abc", List.of(n1.getId(), n2.getId()), true
).thenReturn(objects);
});
// now get the notifications we created
Mono<List<Notification>> listMono = tuple2Mono.flatMap(objects -> {
Notification n1 = objects.getT1();
Notification n2 = objects.getT2();
return notificationRepository.findAllById(List.of(n1.getId(), n2.getId())).collectList();
});
// check that fetched notifications have isRead=true
StepVerifier.create(listMono).assertNext(notifications -> {
Assert.assertEquals(2, notifications.size());
Assert.assertEquals(true, notifications.get(0).getIsRead());
Assert.assertEquals(true, notifications.get(1).getIsRead());
}).verifyComplete();
}
@Test
public void updateIsReadByForUsernameAndIdList_WhenIdNotMatched_UpdatesNone() {
Mono<Notification> saveMono1 = notificationRepository.save(createNotification("abc", false));
Mono<Notification> saveMono2 = notificationRepository.save(createNotification("abc", false));
// create the notifications and then try to update them by different username
Mono<Tuple2<Notification, Notification>> tuple2Mono = Mono.zip(saveMono1, saveMono2).flatMap(objects -> {
Notification n1 = objects.getT1();
Notification n2 = objects.getT2();
return notificationRepository.updateIsReadByForUsernameAndIdList(
"abc", List.of("test-id-1", "test-id-2"), true
).thenReturn(objects);
});
// now get the notifications we created
Mono<List<Notification>> listMono = tuple2Mono.flatMap(objects -> {
Notification n1 = objects.getT1();
Notification n2 = objects.getT2();
return notificationRepository.findAllById(List.of(n1.getId(), n2.getId())).collectList();
});
// check that fetched notifications have isRead=true
StepVerifier.create(listMono).assertNext(notifications -> {
Assert.assertEquals(2, notifications.size());
Assert.assertEquals(false, notifications.get(0).getIsRead());
Assert.assertEquals(false, notifications.get(1).getIsRead());
}).verifyComplete();
}
@Test
public void updateIsReadByForUsername_WhenForUsernameMatched_UpdatesMatchedOnes() {
Mono<Notification> saveMono1 = notificationRepository.save(createNotification("abc", false));
Mono<Notification> saveMono2 = notificationRepository.save(createNotification("abc", false));
Mono<Notification> saveMono3 = notificationRepository.save(createNotification("efg", false));
// create the notifications and then try to update them by same username
Mono<Tuple3<Notification, Notification, Notification>> tuple2Mono = Mono.zip(
saveMono1, saveMono2, saveMono3
).flatMap(objects ->
notificationRepository.updateIsReadByForUsername("abc",true).thenReturn(objects)
);
// now get the notifications we created
Mono<Map<String, Collection<Notification>>> mapMono = tuple2Mono.flatMap(objects -> {
Notification n1 = objects.getT1();
Notification n2 = objects.getT2();
Notification n3 = objects.getT3();
return notificationRepository.findAllById(
List.of(n1.getId(), n2.getId(), n3.getId())
).collectMultimap(Notification::getForUsername);
});
// check that fetched notifications have isRead=true
StepVerifier.create(mapMono).assertNext(notificationCollectionMap -> {
Assert.assertEquals(2, notificationCollectionMap.size()); // should contain map of two keys
Notification forEfg = notificationCollectionMap.get("efg").iterator().next();
Assert.assertEquals(false, forEfg.getIsRead()); // this should be still unread
notificationCollectionMap.get("abc").iterator().forEachRemaining(notification -> {
Assert.assertEquals(true, notification.getIsRead());
});
}).verifyComplete();
}
}

View File

@ -0,0 +1,175 @@
package com.appsmith.server.services;
import com.appsmith.server.domains.Notification;
import com.appsmith.server.domains.User;
import com.appsmith.server.dtos.UpdateIsReadNotificationByIdDTO;
import com.appsmith.server.dtos.UpdateIsReadNotificationDTO;
import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.repositories.NotificationRepository;
import com.mongodb.client.result.UpdateResult;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.convert.MongoConverter;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.test.StepVerifier;
import javax.validation.Validator;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
@RunWith(SpringJUnit4ClassRunner.class)
public class NotificationServiceImplTest {
@MockBean
private Scheduler scheduler;
@MockBean
private Validator validator;
@MockBean
private MongoConverter mongoConverter;
@MockBean
private ReactiveMongoTemplate reactiveMongoTemplate;
@MockBean
private NotificationRepository repository;
@MockBean
private AnalyticsService analyticsService;
@MockBean
private SessionUserService sessionUserService;
NotificationService notificationService;
private User currentUser;
@Before
public void setUp() {
notificationService = new NotificationServiceImpl(
scheduler, validator, mongoConverter, reactiveMongoTemplate,
repository, analyticsService, sessionUserService
);
currentUser = new User();
currentUser.setEmail("sample-email");
Mockito.when(sessionUserService.getCurrentUser()).thenReturn(Mono.just(currentUser));
// mock the repository to return count as 100
Mockito.when(repository.countByForUsername(currentUser.getUsername())).thenReturn(Mono.just(100L));
// mock the repository to return unread count as 5
Mockito.when(repository.countByForUsernameAndIsReadIsTrue(currentUser.getUsername())).thenReturn(Mono.just(5L));
}
private List<Notification> createSampleNotificationList() {
// create some sample notification
List<Notification> notificationList = new ArrayList<>();
for(int i = 1; i <= 5; i++) {
Notification notification = new Notification();
notification.setId("test-id-" + i);
notificationList.add(notification);
}
return notificationList;
}
@Test
public void get_WhenNoBeforeParamProvided_ReturnsData() {
List<Notification> notificationList = createSampleNotificationList();
// mock the repository to return the sample list of notification when called with current time
Mockito.when(repository.findByForUsernameAndCreatedAtBefore(
eq(currentUser.getUsername()), Mockito.any(Instant.class), Mockito.any(Pageable.class))
).thenReturn(Flux.fromIterable(notificationList));
Flux<Notification> notificationFlux = notificationService.get(new LinkedMultiValueMap<>());
StepVerifier
.create(notificationFlux.collectList())
.assertNext(listResponseDTO -> {
assertThat(listResponseDTO.size()).isEqualTo(notificationList.size());
})
.verifyComplete();
}
@Test
public void get_WhenValidBeforeParamExists_ReturnsData() {
List<Notification> notificationList = createSampleNotificationList();
Instant instant = Instant.now();
// mock the repository to return the sample list of notification
Mockito.when(repository.findByForUsernameAndCreatedAtBefore(
eq(currentUser.getUsername()), eq(instant), Mockito.any(Pageable.class))
).thenReturn(Flux.fromIterable(notificationList));
// add the sample pagination parameters
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.put("beforeDate", List.of(instant.toString()));
Flux<Notification> notificationFlux = notificationService.get(params);
StepVerifier
.create(notificationFlux.collectList())
.assertNext(listResponseDTO -> {
assertThat(listResponseDTO.size()).isEqualTo(notificationList.size());
})
.verifyComplete();
}
@Test
public void get_WhenInvalidValidBeforeParam_ThrowsException() {
// add the sample pagination parameters
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.put("beforeDate", List.of("abcd"));
Flux<Notification> notificationFlux = notificationService.get(params);
StepVerifier
.create(notificationFlux.collectList())
.expectErrorMessage(AppsmithError.INVALID_PARAMETER.getMessage("as beforeDate"))
.verify();
}
@Test
public void updateIsRead_WhenUpdateAll_ReturnSuccessfully() {
UpdateIsReadNotificationDTO dto = new UpdateIsReadNotificationDTO();
dto.setIsRead(true);
Mockito.when(repository.updateIsReadByForUsername(currentUser.getUsername(), true)).thenReturn(
Mono.just(Mockito.mock(UpdateResult.class))
);
StepVerifier
.create(notificationService.updateIsRead(dto))
.assertNext(responseDTO -> {
assertThat(responseDTO.getIsRead()).isTrue();
})
.verifyComplete();
}
@Test
public void updateIsRead_WhenUpdateById_ReturnSuccessfully() {
UpdateIsReadNotificationByIdDTO dto = new UpdateIsReadNotificationByIdDTO();
dto.setIsRead(true);
dto.setIdList(List.of("sample-id-1", "sample-id-2", "sample-id-3"));
Mockito.when(repository.updateIsReadByForUsernameAndIdList(
currentUser.getUsername(), dto.getIdList(), true)
).thenReturn(
Mono.just(Mockito.mock(UpdateResult.class))
);
StepVerifier
.create(notificationService.updateIsRead(dto))
.assertNext(responseDTO -> {
assertThat(responseDTO.getIsRead()).isTrue();
assertThat(responseDTO.getIdList()).isEqualTo(dto.getIdList());
})
.verifyComplete();
}
}

View File

@ -1,5 +1,6 @@
package com.appsmith.server.solutions;
import com.appsmith.server.configurations.InstanceConfig;
import com.appsmith.server.domains.Application;
import com.appsmith.server.domains.Organization;
import com.appsmith.server.services.ApplicationPageService;
@ -13,6 +14,7 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.test.context.support.WithUserDetails;
@ -51,6 +53,9 @@ public class ExampleApplicationsAreMarked {
@MockBean
private ConfigService configService;
@MockBean
private InstanceConfig instanceConfig;
@Test
@WithUserDetails(value = "api_user")
public void exampleApplicationsAreMarked() {
@ -66,7 +71,9 @@ public class ExampleApplicationsAreMarked {
assert organization.getId() != null;
Mockito.when(configService.getTemplateOrganizationId()).thenReturn(Mono.just(organization.getId()));
Mockito.doNothing().when(instanceConfig).onApplicationEvent(
Mockito.any(ApplicationReadyEvent.class)
);
// Create 4 applications inside the example organization but only mark three applications as example
final Application app1 = new Application();
app1.setName("first application");

View File

@ -107,6 +107,28 @@ node versions to be used in different projects. Check below for installation and
yarn start
```
### Windows WSL2 Setup
Before you follow the instructions above, make sure to check the following steps:
1. You have **WSL2** setup in your machine. If not, please visit: [https://docs.microsoft.com/en-us/windows/wsl/install-win10](https://docs.microsoft.com/en-us/windows/wsl/install-win10).
2. You have [Node.js](https://www.geeksforgeeks.org/installation-of-node-js-on-linux/) installed on the WSL Distro.
3. You have **Docker Desktop** installed with WSL2 backend. If not, please visit: [https://docs.docker.com/docker-for-windows/wsl/](https://docs.docker.com/docker-for-windows/wsl/).
In the above [Docker Desktop Setup](https://docs.docker.com/docker-for-windows/wsl/) instructions, make sure to:
1. Set WSL Distro to run in WSL2 mode.
2. Enable integration with the WSL Distro in Docker Desktop.
3. Install [Remote-WSL](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) extension in VSCode.
Make sure to Clone the Repo in the WSL file system instead of the Windows file System.
And finally, you can open the folder in VSCode with WSL by following the instructions in [Docker Desktop Setup](https://docs.docker.com/docker-for-windows/wsl/),
or Alternatively by,
1. Clicking on the Green button on the Bottom Left corner in VSCode.
2. Selecting **Open Folder in WSL** and navigating to the folder in WSL.
After this You can continue Setting up from [here](#pre-requisites).
### Troubleshooting
#### I am on WSL and can't reach dev.appsmith.com