diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/ActionExecution/ExecutionParams_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/ActionExecution/ExecutionParams_spec.js index 249f28f43b..4a50dfc688 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/ActionExecution/ExecutionParams_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/ActionExecution/ExecutionParams_spec.js @@ -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) => { diff --git a/app/client/src/AppRouter.tsx b/app/client/src/AppRouter.tsx index 1ba02d5b77..430762a01b 100644 --- a/app/client/src/AppRouter.tsx +++ b/app/client/src/AppRouter.tsx @@ -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 { exact path={APPLICATIONS_URL} /> + { 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...", diff --git a/app/client/src/assets/icons/comments/eye.svg b/app/client/src/assets/icons/comments/eye.svg new file mode 100644 index 0000000000..6ce21f811e --- /dev/null +++ b/app/client/src/assets/icons/comments/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/comments/AppComments/AppCommentThreads.tsx b/app/client/src/comments/AppComments/AppCommentThreads.tsx index d4b5dad56b..e3fc2d4036 100644 --- a/app/client/src/comments/AppComments/AppCommentThreads.tsx +++ b/app/client/src/comments/AppComments/AppCommentThreads.tsx @@ -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, ], ); diff --git a/app/client/src/comments/AppComments/Container.tsx b/app/client/src/comments/AppComments/Container.tsx index 3aaf327c6c..396f221d86 100644 --- a/app/client/src/comments/AppComments/Container.tsx +++ b/app/client/src/comments/AppComments/Container.tsx @@ -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; `; diff --git a/app/client/src/comments/CommentCard/CommentCard.tsx b/app/client/src/comments/CommentCard/CommentCard.tsx index c73f417f6d..9f5953912c 100644 --- a/app/client/src/comments/CommentCard/CommentCard.tsx +++ b/app/client/src/comments/CommentCard/CommentCard.tsx @@ -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({ {authorName} diff --git a/app/client/src/comments/CommentsShowcaseCarousel/ProfileForm.tsx b/app/client/src/comments/CommentsShowcaseCarousel/ProfileForm.tsx index 7cde64e8d1..03f749f8b1 100644 --- a/app/client/src/comments/CommentsShowcaseCarousel/ProfileForm.tsx +++ b/app/client/src/comments/CommentsShowcaseCarousel/ProfileForm.tsx @@ -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 (
- +
>>, ) => { 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); diff --git a/app/client/src/comments/inlineComments/useOrgUsers.tsx b/app/client/src/comments/inlineComments/useOrgUsers.tsx index 642a49c28b..bcf055d5f1 100644 --- a/app/client/src/comments/inlineComments/useOrgUsers.tsx +++ b/app/client/src/comments/inlineComments/useOrgUsers.tsx @@ -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; }; diff --git a/app/client/src/comments/utils.ts b/app/client/src/comments/utils.ts index 2a863d77ea..fbd25f8116 100644 --- a/app/client/src/comments/utils.ts +++ b/app/client/src/comments/utils.ts @@ -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 = { 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, diff --git a/app/client/src/components/ads/DisplayImageUpload.tsx b/app/client/src/components/ads/DisplayImageUpload.tsx index fb35df09fe..8bd949d98f 100644 --- a/app/client/src/components/ads/DisplayImageUpload.tsx +++ b/app/client/src/components/ads/DisplayImageUpload.tsx @@ -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) { {defaultLabel} )} + {value && !loadError && ( + { + e.preventDefault(); + e.stopPropagation(); + if (onRemove) onRemove(); + }} + > + {createMessage(REMOVE)} + + )} } > diff --git a/app/client/src/components/ads/MentionsInput.tsx b/app/client/src/components/ads/MentionsInput.tsx index 87d4ed8a39..18640a24db 100644 --- a/app/client/src/components/ads/MentionsInput.tsx +++ b/app/client/src/components/ads/MentionsInput.tsx @@ -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) {
- {user?.username} + {props.mention.name} {user?.username}
diff --git a/app/client/src/comments/CommentsShowcaseCarousel/FormDisplayImage.tsx b/app/client/src/components/ads/UserProfileImagePicker.tsx similarity index 81% rename from app/client/src/comments/CommentsShowcaseCarousel/FormDisplayImage.tsx rename to app/client/src/components/ads/UserProfileImagePicker.tsx index d269d5612a..72a50becb8 100644 --- a/app/client/src/comments/CommentsShowcaseCarousel/FormDisplayImage.tsx +++ b/app/client/src/components/ads/UserProfileImagePicker.tsx @@ -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 ( diff --git a/app/client/src/components/editorComponents/GlobalSearch/index.tsx b/app/client/src/components/editorComponents/GlobalSearch/index.tsx index 07577ddb08..ec1c11cc97 100644 --- a/app/client/src/components/editorComponents/GlobalSearch/index.tsx +++ b/app/client/src/components/editorComponents/GlobalSearch/index.tsx @@ -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; diff --git a/app/client/src/constants/messages.ts b/app/client/src/constants/messages.ts index 717f38b2e9..e70bfb60b6 100644 --- a/app/client/src/constants/messages.ts +++ b/app/client/src/constants/messages.ts @@ -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"; diff --git a/app/client/src/constants/routes.ts b/app/client/src/constants/routes.ts index 9fe215b42f..f7f11a37b9 100644 --- a/app/client/src/constants/routes.ts +++ b/app/client/src/constants/routes.ts @@ -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()); diff --git a/app/client/src/constants/userConstants.ts b/app/client/src/constants/userConstants.ts index 17e69835a1..d66e873a9c 100644 --- a/app/client/src/constants/userConstants.ts +++ b/app/client/src/constants/userConstants.ts @@ -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"; diff --git a/app/client/src/entities/Comments/CommentsInterfaces.ts b/app/client/src/entities/Comments/CommentsInterfaces.ts index 18f30eff35..c394d2a1c0 100644 --- a/app/client/src/entities/Comments/CommentsInterfaces.ts +++ b/app/client/src/entities/Comments/CommentsInterfaces.ts @@ -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 = { diff --git a/app/client/src/globalStyles/portals.ts b/app/client/src/globalStyles/portals.ts index a1d80c6fc1..9200588eac 100644 --- a/app/client/src/globalStyles/portals.ts +++ b/app/client/src/globalStyles/portals.ts @@ -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; + } `; diff --git a/app/client/src/notifications/NotificationListItem.tsx b/app/client/src/notifications/NotificationListItem.tsx index 0c9018f388..6a3e5fec0a 100644 --- a/app/client/src/notifications/NotificationListItem.tsx +++ b/app/client/src/notifications/NotificationListItem.tsx @@ -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, }); diff --git a/app/client/src/pages/AppViewer/AppViewerPageContainer.tsx b/app/client/src/pages/AppViewer/AppViewerPageContainer.tsx index 3d9371d7c9..dcdd40e1ed 100644 --- a/app/client/src/pages/AppViewer/AppViewerPageContainer.tsx +++ b/app/client/src/pages/AppViewer/AppViewerPageContainer.tsx @@ -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; diff --git a/app/client/src/pages/AppViewer/GlobalHotKeys.tsx b/app/client/src/pages/AppViewer/GlobalHotKeys.tsx new file mode 100644 index 0000000000..bd63a40f5e --- /dev/null +++ b/app/client/src/pages/AppViewer/GlobalHotKeys.tsx @@ -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 { + public renderHotkeys() { + return ( + + { + this.props.resetCommentMode(); + e.preventDefault(); + }} + /> + + setCommentModeInUrl(true)} + /> + + ); + } + + render() { + return
{this.props.children}
; + } +} + +const mapDispatchToProps = (dispatch: any) => { + return { + resetCommentMode: () => dispatch(setCommentModeAction(false)), + }; +}; + +export default connect(null, mapDispatchToProps)(GlobalHotKeys); diff --git a/app/client/src/pages/AppViewer/index.tsx b/app/client/src/pages/AppViewer/index.tsx index 519769b606..d947a2f620 100644 --- a/app/client/src/pages/AppViewer/index.tsx +++ b/app/client/src/pages/AppViewer/index.tsx @@ -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; class AppViewer extends Component< @@ -98,36 +105,41 @@ class AppViewer extends Component< public render() { const { isInitialized } = this.props; return ( - - - - - 1}> - {isInitialized && this.state.registered && ( - - - - - )} - - - - - + + + + + + + 1}> + {isInitialized && this.state.registered && ( + + + + + )} + + + + + + + + ); } } @@ -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) => ({ diff --git a/app/client/src/pages/AppViewer/viewer/AppViewerHeader.tsx b/app/client/src/pages/AppViewer/viewer/AppViewerHeader.tsx index 7dfeec96a4..3a2d7242af 100644 --- a/app/client/src/pages/AppViewer/viewer/AppViewerHeader.tsx +++ b/app/client/src/pages/AppViewer/viewer/AppViewerHeader.tsx @@ -171,9 +171,14 @@ export function AppViewerHeader(props: AppViewerHeaderProps) { - - - +
+ + + +
+
+ +
{currentApplicationDetails && ( @@ -181,7 +186,6 @@ export function AppViewerHeader(props: AppViewerHeaderProps) { )} - {currentApplicationDetails && ( <> ))} @@ -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() { diff --git a/app/client/src/pages/Editor/EditorHeader.tsx b/app/client/src/pages/Editor/EditorHeader.tsx index 7072847f95..56159d9784 100644 --- a/app/client/src/pages/Editor/EditorHeader.tsx +++ b/app/client/src/pages/Editor/EditorHeader.tsx @@ -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; diff --git a/app/client/src/pages/Editor/ToggleModeButton.tsx b/app/client/src/pages/Editor/ToggleModeButton.tsx index 55199fa4e5..1998bec770 100644 --- a/app/client/src/pages/Editor/ToggleModeButton.tsx +++ b/app/client/src/pages/Editor/ToggleModeButton.tsx @@ -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 ( + + Edit Mode + V + + } + hoverOpenDelay={1000} + position={Position.BOTTOM} + > + + + ); +} + +function ViewModeReset() { + return ( + + View Mode + V + + } + hoverOpenDelay={1000} + position={Position.BOTTOM} + > + + + ); +} + 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)} > - - Edit Mode - V - - } - hoverOpenDelay={1000} - position={Position.BOTTOM} - > - - + {mode === APP_MODE.EDIT ? : } 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 ( - + + + Display Picture + + + + Display name {isFetchingUser && } {!isFetchingUser && ( - +
+ +
)} -
+ Email @@ -118,18 +122,7 @@ function General() { - {/* Commenting for now until the image related apis are ready */} - {/* - - Display Picture - - - - + {/* Website diff --git a/app/client/src/pages/common/ProfileDropdown.tsx b/app/client/src/pages/common/ProfileDropdown.tsx index ccc18a6e9b..a9d4c34d29 100644 --- a/app/client/src/pages/common/ProfileDropdown.tsx +++ b/app/client/src/pages/common/ProfileDropdown.tsx @@ -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 = ; + const Profile = ( + + ); return ( <> diff --git a/app/client/src/pages/organization/General.tsx b/app/client/src/pages/organization/General.tsx index b426b6230d..7e320be84f 100644 --- a/app/client/src/pages/organization/General.tsx +++ b/app/client/src/pages/organization/General.tsx @@ -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; diff --git a/app/client/src/pages/organization/OrgInviteUsersForm.tsx b/app/client/src/pages/organization/OrgInviteUsersForm.tsx index ecdfc6203b..fe1b72ff81 100644 --- a/app/client/src/pages/organization/OrgInviteUsersForm.tsx +++ b/app/client/src/pages/organization/OrgInviteUsersForm.tsx @@ -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) { - + {user.name} {user.username} diff --git a/app/client/src/sagas/CommentSagas/index.ts b/app/client/src/sagas/CommentSagas/index.ts index 1b0585d4b4..bdaacf8e2b 100644 --- a/app/client/src/sagas/CommentSagas/index.ts +++ b/app/client/src/sagas/CommentSagas/index.ts @@ -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>, @@ -57,10 +58,12 @@ function* createCommentThread(action: ReduxAction) { ); 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); diff --git a/app/client/src/selectors/commentsSelectors.ts b/app/client/src/selectors/commentsSelectors.ts index 231d3c6367..3f90e97f64 100644 --- a/app/client/src/selectors/commentsSelectors.ts +++ b/app/client/src/selectors/commentsSelectors.ts @@ -83,7 +83,6 @@ export const getSortedAndFilteredAppCommentThreadIds = ( shouldShowResolved: boolean, appCommentsFilter: typeof filterOptions[number]["value"], currentUserUsername?: string, - currentPageId?: string, ): Array => { 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; diff --git a/app/client/src/selectors/themeSelectors.tsx b/app/client/src/selectors/themeSelectors.tsx index 23f1b14ed3..aeb85b86ae 100644 --- a/app/client/src/selectors/themeSelectors.tsx +++ b/app/client/src/selectors/themeSelectors.tsx @@ -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 => diff --git a/app/client/src/utils/AppsmithUtils.tsx b/app/client/src/utils/AppsmithUtils.tsx index 20976e2c59..d6ddfbfa5a 100644 --- a/app/client/src/utils/AppsmithUtils.tsx +++ b/app/client/src/utils/AppsmithUtils.tsx @@ -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(); diff --git a/app/client/src/utils/PerformanceTracker.ts b/app/client/src/utils/PerformanceTracker.ts index 68addb9343..99f56fccea 100644 --- a/app/client/src/utils/PerformanceTracker.ts +++ b/app/client/src/utils/PerformanceTracker.ts @@ -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 = [], ) => { 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), diff --git a/app/server/appsmith-plugins/pom.xml b/app/server/appsmith-plugins/pom.xml index 1c066b8540..cd0f9c7d1f 100644 --- a/app/server/appsmith-plugins/pom.xml +++ b/app/server/appsmith-plugins/pom.xml @@ -27,5 +27,6 @@ redshiftPlugin amazons3Plugin googleSheetsPlugin + snowflakePlugin \ No newline at end of file diff --git a/app/server/appsmith-plugins/postgresPlugin/src/main/java/com/external/plugins/PostgresPlugin.java b/app/server/appsmith-plugins/postgresPlugin/src/main/java/com/external/plugins/PostgresPlugin.java index cb0ad956aa..e3f789efdd 100644 --- a/app/server/appsmith-plugins/postgresPlugin/src/main/java/com/external/plugins/PostgresPlugin.java +++ b/app/server/appsmith-plugins/postgresPlugin/src/main/java/com/external/plugins/PostgresPlugin.java @@ -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(); diff --git a/app/server/appsmith-plugins/postgresPlugin/src/test/java/com/external/plugins/PostgresPluginTest.java b/app/server/appsmith-plugins/postgresPlugin/src/test/java/com/external/plugins/PostgresPluginTest.java index a612400f58..e89f834d91 100644 --- a/app/server/appsmith-plugins/postgresPlugin/src/test/java/com/external/plugins/PostgresPluginTest.java +++ b/app/server/appsmith-plugins/postgresPlugin/src/test/java/com/external/plugins/PostgresPluginTest.java @@ -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 connectionPoolMono = pluginExecutor.datasourceCreate(dsConfig); + Mono 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(); + } } diff --git a/app/server/appsmith-plugins/snowflakePlugin/plugin.properties b/app/server/appsmith-plugins/snowflakePlugin/plugin.properties new file mode 100644 index 0000000000..8de5730bcc --- /dev/null +++ b/app/server/appsmith-plugins/snowflakePlugin/plugin.properties @@ -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= diff --git a/app/server/appsmith-plugins/snowflakePlugin/pom.xml b/app/server/appsmith-plugins/snowflakePlugin/pom.xml new file mode 100644 index 0000000000..c8f8046c31 --- /dev/null +++ b/app/server/appsmith-plugins/snowflakePlugin/pom.xml @@ -0,0 +1,236 @@ + + + + 4.0.0 + + com.external.plugins + snowflakePlugin + 1.0-SNAPSHOT + + snowflakePlugin + + + UTF-8 + 11 + ${java.version} + ${java.version} + snowflake-plugin + com.external.plugins.SnowflakePlugin + 1.0-SNAPSHOT + tech@appsmith.com + + + + + + + org.pf4j + pf4j-spring + 0.7.0 + provided + + + + com.appsmith + interfaces + 1.0-SNAPSHOT + provided + + + + org.springframework + spring-core + 5.2.3.RELEASE + provided + + + + org.springframework + spring-web + 5.2.3.RELEASE + provided + + + + org.springframework + spring-webflux + 5.2.3.RELEASE + + + io.projectreactor + reactor-core + + + org.springframework + spring-core + + + org.springframework + spring-web + + + + + + net.snowflake + snowflake-jdbc + 3.13.4 + + + + org.projectlombok + lombok + 1.18.8 + + + + com.fasterxml.jackson.core + jackson-databind + 2.10.5.1 + provided + + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + 2.10.2 + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.10.2 + + + + io.jsonwebtoken + jjwt-api + 0.11.2 + + + io.jsonwebtoken + jjwt-impl + 0.11.2 + + + io.jsonwebtoken + jjwt-jackson + 0.11.2 + + + + + junit + junit + 4.13.1 + test + + + + io.projectreactor + reactor-test + 3.3.5.RELEASE + test + + + + org.mockito + mockito-core + 3.1.0 + test + + + + org.assertj + assertj-core + 3.13.2 + test + + + + + org.powermock + powermock-module-junit4 + 2.0.9 + test + + + + + org.powermock + powermock-api-mockito2 + 2.0.9 + test + + + + org.springframework.boot + spring-boot-starter-webflux + 2.2.4.RELEASE + test + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + false + + + + ${plugin.id} + ${plugin.class} + ${plugin.version} + ${plugin.provider} + + + + + + + package + + shade + + + + + + maven-dependency-plugin + + + copy-dependencies + package + + copy + + + ${project.build.directory}/lib + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + + + io.jsonwebtoken + jjwt-jackson + + + + + + + + + + diff --git a/app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/plugins/SnowflakePlugin.java b/app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/plugins/SnowflakePlugin.java new file mode 100644 index 0000000000..40dce8873f --- /dev/null +++ b/app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/plugins/SnowflakePlugin.java @@ -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 { + + private final Scheduler scheduler = Schedulers.elastic(); + + @Override + public Mono 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> 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 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 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 validateDatasource(DatasourceConfiguration datasourceConfiguration) { + Set 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 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 getStructure(Connection connection, DatasourceConfiguration datasourceConfiguration) { + final DatasourceStructure structure = new DatasourceStructure(); + final Map tablesByName = new LinkedHashMap<>(); + final Map 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); + } + } +} diff --git a/app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/utils/SqlUtils.java b/app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/utils/SqlUtils.java new file mode 100644 index 0000000000..40dac1bfc4 --- /dev/null +++ b/app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/utils/SqlUtils.java @@ -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 tablesByName) { + for (DatasourceStructure.Table table : tablesByName.values()) { + final List columnsWithoutDefault = table.getColumns() + .stream() + .filter(column -> column.getDefaultValue() == null) + .collect(Collectors.toList()); + + final List columnNames = new ArrayList<>(); + final List 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 tablesByName, Map 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 tablesByName, Map 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 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") + )); + } +} diff --git a/app/server/appsmith-plugins/snowflakePlugin/src/main/resources/editor.json b/app/server/appsmith-plugins/snowflakePlugin/src/main/resources/editor.json new file mode 100644 index 0000000000..28fc44800b --- /dev/null +++ b/app/server/appsmith-plugins/snowflakePlugin/src/main/resources/editor.json @@ -0,0 +1,16 @@ +{ + "editor": [ + { + "sectionName": "", + "id": 1, + "children": [ + { + "label": "", + "internalLabel": "Query", + "configProperty": "actionConfiguration.body", + "controlType": "QUERY_DYNAMIC_TEXT" + } + ] + } + ] +} \ No newline at end of file diff --git a/app/server/appsmith-plugins/snowflakePlugin/src/main/resources/form.json b/app/server/appsmith-plugins/snowflakePlugin/src/main/resources/form.json new file mode 100644 index 0000000000..a4f196750e --- /dev/null +++ b/app/server/appsmith-plugins/snowflakePlugin/src/main/resources/form.json @@ -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 + } + ] + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/snowflakePlugin/src/test/java/com/external/plugins/SnowflakePluginTest.java b/app/server/appsmith-plugins/snowflakePlugin/src/test/java/com/external/plugins/SnowflakePluginTest.java new file mode 100644 index 0000000000..ba164a4749 --- /dev/null +++ b/app/server/appsmith-plugins/snowflakePlugin/src/test/java/com/external/plugins/SnowflakePluginTest.java @@ -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 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.")); + } + +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/AuthenticationSuccessHandler.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/AuthenticationSuccessHandler.java index ae48813763..8c78cd7c20 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/AuthenticationSuccessHandler.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/AuthenticationSuccessHandler.java @@ -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 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 handleOAuth2Redirect(WebFilterExchange webFilterExchange) { + private Mono 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 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); + } + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/NotificationController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/NotificationController.java index b7a00dec57..b958f76fc1 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/NotificationController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/NotificationController.java @@ -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 { - @Autowired public NotificationController(NotificationService service) { super(service); } + @GetMapping("count/unread") + public Mono> getUnreadCount() { + return service.getUnreadCount() + .map(response -> new ResponseDTO<>(HttpStatus.OK.value(), response, null)); + } + + @PatchMapping("isRead") + public Mono> 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> 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> create(Notification resource, String originHeader, ServerWebExchange exchange) { + return Mono.error(new AppsmithException(UNSUPPORTED_OPERATION)); + } + + @Override + public Mono> update(String s, Notification resource) { + return Mono.error(new AppsmithException(UNSUPPORTED_OPERATION)); + } + + @Override + public Mono> delete(String s) { + return Mono.error(new AppsmithException(UNSUPPORTED_OPERATION)); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Comment.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Comment.java index cdfd85ba9e..d2c48ada0c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Comment.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Comment.java @@ -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 reactions; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThread.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThread.java index 288b6347d2..538f4528d4 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThread.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThread.java @@ -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 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; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Notification.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Notification.java index 79ffee5af1..3f46bc9e82 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Notification.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Notification.java @@ -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(); + } + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/UpdateIsReadNotificationByIdDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/UpdateIsReadNotificationByIdDTO.java new file mode 100644 index 0000000000..1b4aa2c812 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/UpdateIsReadNotificationByIdDTO.java @@ -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 idList; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/UpdateIsReadNotificationDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/UpdateIsReadNotificationDTO.java new file mode 100644 index 0000000000..fb85db61a1 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/UpdateIsReadNotificationDTO.java @@ -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; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/NumberUtils.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/NumberUtils.java new file mode 100644 index 0000000000..471593e20b --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/NumberUtils.java @@ -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; + } + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java index 443a991992..36d960405b 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java @@ -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"; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java index 3ca1ab4cc6..c63bf5bf96 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java @@ -2392,16 +2392,16 @@ public class DatabaseChangelog { @ChangeSet(order = "071", id = "add-application-export-permissions", author = "") public void addApplicationExportPermissions(MongoTemplate mongoTemplate) { final List organizations = mongoTemplate.find( - query(where("userRoles").exists(true)), - Organization.class + query(where("userRoles").exists(true)), + Organization.class ); for (final Organization organization : organizations) { Set 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 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 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 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()); + + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepository.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepository.java index 9f420f5cf7..48aeeaa18a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepository.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepository.java @@ -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 { + Mono updateIsReadByForUsernameAndIdList(String forUsername, List idList, boolean isRead); + Mono updateIsReadByForUsername(String forUsername, boolean isRead); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepositoryImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepositoryImpl.java index 6c4b50c95c..136f380d9f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepositoryImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepositoryImpl.java @@ -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 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 + implements CustomNotificationRepository { public CustomNotificationRepositoryImpl(ReactiveMongoOperations mongoOperations, MongoConverter mongoConverter) { super(mongoOperations, mongoConverter); } + @Override + public Mono updateIsReadByForUsernameAndIdList(String forUsername, List 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 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 + ); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/NotificationRepository.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/NotificationRepository.java index eb81b59bb5..47f146487d 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/NotificationRepository.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/NotificationRepository.java @@ -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, CustomNotificationRepository { - - Flux findByForUsername(String userId); - + Flux findByForUsername(String userId, Pageable pageable); + Flux findByForUsernameAndCreatedAtBefore(String userId, Instant instant, Pageable pageable); + Mono countByForUsername(String userId); + Mono countByForUsernameAndIsReadIsTrue(String userId); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentServiceImpl.java index 7643c784bb..88117103c1 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentServiceImpl.java @@ -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 policies = policyGenerator.getAllChildPolicies( thread.getPolicies(), @@ -148,10 +147,9 @@ public class CommentServiceImpl extends BaseService> notificationMonos = new ArrayList<>(); for (String username : usernames) { if (!username.equals(user.getUsername())) { - final CommentNotification notification = new CommentNotification(); - notification.setComment(savedComment); - notification.setForUsername(username); - Mono notificationMono = notificationService.create(notification); + Mono notificationMono = notificationService.createNotification( + savedComment, username + ); notificationMonos.add(notificationMono); } } @@ -194,6 +192,9 @@ public class CommentServiceImpl extends BaseService { final User user = tuple.getT1(); final Application application = tuple.getT2(); + commentThread.setApplicationName(application.getName()); + commentThread.setAuthorName(user.getName()); + commentThread.setAuthorUsername(user.getUsername()); final Set policies = policyGenerator.getAllChildPolicies( application.getPolicies(), @@ -244,10 +245,7 @@ public class CommentServiceImpl extends BaseService> 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)); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationService.java index 8c4d9cb739..05c099e2ba 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationService.java @@ -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 { - + Mono createNotification(Comment comment, String forUsername); + Mono createNotification(CommentThread commentThread, String forUsername, User authorUser); + Mono updateIsRead(UpdateIsReadNotificationByIdDTO dto); + Mono updateIsRead(UpdateIsReadNotificationDTO dto); + Mono getUnreadCount(); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationServiceImpl.java index 0b88af94c5..6f4dce667d 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationServiceImpl.java @@ -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 get(MultiValueMap 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 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 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 updateIsRead(UpdateIsReadNotificationByIdDTO dto) { + return sessionUserService.getCurrentUser() + .flatMap(user -> + repository.updateIsReadByForUsernameAndIdList( + user.getUsername(), dto.getIdList(), dto.getIsRead() + ).thenReturn(dto) + ); + } + + @Override + public Mono updateIsRead(UpdateIsReadNotificationDTO dto) { + return sessionUserService.getCurrentUser() + .flatMap(user -> repository.updateIsReadByForUsername(user.getUsername(), dto.getIsRead()) + .thenReturn(dto) + ); + } + + @Override + public Mono getUnreadCount() { + return sessionUserService.getCurrentUser().flatMap(user -> + repository.countByForUsernameAndIsReadIsTrue(user.getUsername()) + ); } } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/NumberUtilsTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/NumberUtilsTest.java new file mode 100644 index 0000000000..eb0f16ce54 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/NumberUtilsTest.java @@ -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)); + } +} \ No newline at end of file diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/repositories/CustomNotificationRepositoryImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/repositories/CustomNotificationRepositoryImplTest.java new file mode 100644 index 0000000000..49890445ee --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/repositories/CustomNotificationRepositoryImplTest.java @@ -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 saveMono1 = notificationRepository.save(createNotification("abc", false)); + Mono saveMono2 = notificationRepository.save(createNotification("efg", false)); + + // create the notifications and then try to update them by different username + Mono> 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> 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 saveMono1 = notificationRepository.save(createNotification("abc", false)); + Mono saveMono2 = notificationRepository.save(createNotification("abc", false)); + + // create the notifications and then try to update them by same username + Mono> 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> 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 saveMono1 = notificationRepository.save(createNotification("abc", false)); + Mono saveMono2 = notificationRepository.save(createNotification("abc", false)); + + // create the notifications and then try to update them by different username + Mono> 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> 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 saveMono1 = notificationRepository.save(createNotification("abc", false)); + Mono saveMono2 = notificationRepository.save(createNotification("abc", false)); + Mono saveMono3 = notificationRepository.save(createNotification("efg", false)); + + // create the notifications and then try to update them by same username + Mono> tuple2Mono = Mono.zip( + saveMono1, saveMono2, saveMono3 + ).flatMap(objects -> + notificationRepository.updateIsReadByForUsername("abc",true).thenReturn(objects) + ); + + // now get the notifications we created + Mono>> 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(); + } +} \ No newline at end of file diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/NotificationServiceImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/NotificationServiceImplTest.java new file mode 100644 index 0000000000..1cd6ee747b --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/NotificationServiceImplTest.java @@ -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 createSampleNotificationList() { + // create some sample notification + List 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 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 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 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 params = new LinkedMultiValueMap<>(); + params.put("beforeDate", List.of(instant.toString())); + + Flux 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 params = new LinkedMultiValueMap<>(); + params.put("beforeDate", List.of("abcd")); + + Flux 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(); + } +} \ No newline at end of file diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ExampleApplicationsAreMarked.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ExampleApplicationsAreMarked.java index ee49e1c7a6..c11deb3dcb 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ExampleApplicationsAreMarked.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ExampleApplicationsAreMarked.java @@ -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"); diff --git a/contributions/ClientSetup.md b/contributions/ClientSetup.md index 5eb3fb1a3d..5ffd5e6a63 100644 --- a/contributions/ClientSetup.md +++ b/contributions/ClientSetup.md @@ -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