Merge branch 'release' into buttonUITestCases
This commit is contained in:
commit
95382420df
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
getApplicationViewerPageURL,
|
||||
ORG_URL,
|
||||
SIGN_UP_URL,
|
||||
SIGNUP_SUCCESS_URL,
|
||||
USER_AUTH_URL,
|
||||
USERS_URL,
|
||||
PROFILE,
|
||||
|
|
@ -47,7 +48,8 @@ function changeAppBackground(currentTheme: any) {
|
|||
if (
|
||||
trimTrailingSlash(window.location.pathname) === "/applications" ||
|
||||
window.location.pathname.indexOf("/settings/") !== -1 ||
|
||||
trimTrailingSlash(window.location.pathname) === "/profile"
|
||||
trimTrailingSlash(window.location.pathname) === "/profile" ||
|
||||
trimTrailingSlash(window.location.pathname) === "/signup-success"
|
||||
) {
|
||||
document.body.style.backgroundColor =
|
||||
currentTheme.colors.homepageBackground;
|
||||
|
|
@ -101,6 +103,11 @@ class AppRouter extends React.Component<any, any> {
|
|||
exact
|
||||
path={APPLICATIONS_URL}
|
||||
/>
|
||||
<SentryRoute
|
||||
component={ApplicationListLoader}
|
||||
exact
|
||||
path={SIGNUP_SUCCESS_URL}
|
||||
/>
|
||||
<SentryRoute component={EditorLoader} path={BUILDER_URL} />
|
||||
<SentryRoute
|
||||
component={AppViewerLoader}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import {
|
|||
ERROR_CODES,
|
||||
SERVER_ERROR_CODES,
|
||||
} from "constants/ApiConstants";
|
||||
import history from "utils/history";
|
||||
import { AUTH_LOGIN_URL } from "constants/routes";
|
||||
import log from "loglevel";
|
||||
import { ActionExecutionResponse } from "api/ActionAPI";
|
||||
import store from "store";
|
||||
import { logoutUser } from "actions/userActions";
|
||||
|
||||
const executeActionRegex = /actions\/execute/;
|
||||
const timeoutErrorRegex = /timeout of (\d+)ms exceeded/;
|
||||
|
|
@ -100,10 +100,9 @@ export const apiFailureResponseInterceptor = (error: any) => {
|
|||
const currentUrl = `${window.location.href}`;
|
||||
if (error.response.status === API_STATUS_CODES.REQUEST_NOT_AUTHORISED) {
|
||||
// Redirect to login and set a redirect url.
|
||||
history.replace({
|
||||
pathname: AUTH_LOGIN_URL,
|
||||
search: `redirectUrl=${encodeURIComponent(currentUrl)}`,
|
||||
});
|
||||
store.dispatch(
|
||||
logoutUser({ redirectURL: encodeURIComponent(currentUrl) }),
|
||||
);
|
||||
return Promise.reject({
|
||||
code: ERROR_CODES.REQUEST_NOT_AUTHORISED,
|
||||
message: "Unauthorized. Redirecting to login page...",
|
||||
|
|
|
|||
3
app/client/src/assets/icons/comments/eye.svg
Normal file
3
app/client/src/assets/icons/comments/eye.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.6101 7.33577C13.7584 5.80292 11.8038 5 10.0034 5C5.66552 5 2.25348 9.4708 2.11631 9.65328C1.96123 9.85496 1.96123 10.145 2.11631 10.3467C2.79556 11.2046 3.56016 11.9817 4.39672 12.6642C6.24848 14.1971 8.20311 15 10.0034 15C14.3413 15 17.7534 10.5292 17.8906 10.3285C18.0365 10.1302 18.0365 9.85155 17.8906 9.65328C17.2112 8.79548 16.4466 8.01845 15.6101 7.33577ZM10.0034 13.3507C8.07167 13.3507 6.50567 11.8507 6.50567 10.0004C6.50567 8.15001 8.07167 6.65 10.0034 6.65C11.9352 6.65 13.5012 8.15001 13.5012 10.0004C13.4918 11.847 11.9313 13.3417 10.0034 13.3507ZM8.10024 10.003C8.10024 8.99618 8.95233 8.18 10.0034 8.18C11.0545 8.18 11.9066 8.99618 11.9066 10.003C11.9066 11.0098 11.0545 11.826 10.0034 11.826C8.95233 11.826 8.10024 11.0098 8.10024 10.003Z" fill="#858282"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 930 B |
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import copy from "copy-to-clipboard";
|
|||
import moment from "moment";
|
||||
import history from "utils/history";
|
||||
|
||||
import UserApi from "api/UserApi";
|
||||
import { getAppMode } from "selectors/applicationSelectors";
|
||||
|
||||
import { USER_PHOTO_URL } from "constants/userConstants";
|
||||
|
||||
import { getCommentThreadURL } from "../utils";
|
||||
|
||||
|
|
@ -49,10 +51,7 @@ import { createMessage, LINK_COPIED_SUCCESSFULLY } from "constants/messages";
|
|||
import { Variant } from "components/ads/common";
|
||||
import TourTooltipWrapper from "components/ads/tour/TourTooltipWrapper";
|
||||
import { TourType } from "entities/Tour";
|
||||
import {
|
||||
getCurrentApplicationId,
|
||||
getCurrentPageId,
|
||||
} from "selectors/editorSelectors";
|
||||
import { getCurrentApplicationId } from "selectors/editorSelectors";
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
|
|
@ -270,18 +269,20 @@ function CommentCard({
|
|||
const pinnedByUsername = commentThread.pinnedState?.authorUsername;
|
||||
let pinnedBy = commentThread.pinnedState?.authorName;
|
||||
|
||||
const appMode = useSelector(getAppMode);
|
||||
|
||||
if (currentUserUsername === pinnedByUsername) {
|
||||
pinnedBy = "You";
|
||||
}
|
||||
|
||||
const pageId = useSelector(getCurrentPageId);
|
||||
const applicationId = useSelector(getCurrentApplicationId);
|
||||
|
||||
const commentThreadURL = getCommentThreadURL({
|
||||
applicationId,
|
||||
commentThreadId,
|
||||
isResolved: !!commentThread?.resolvedState?.active,
|
||||
pageId,
|
||||
pageId: commentThread?.pageId,
|
||||
mode: appMode,
|
||||
});
|
||||
|
||||
const copyCommentLink = () => {
|
||||
|
|
@ -394,7 +395,7 @@ function CommentCard({
|
|||
<HeaderSection>
|
||||
<ProfileImage
|
||||
side={25}
|
||||
source={`/api/${UserApi.photoURL}/${authorUsername}`}
|
||||
source={`/api/${USER_PHOTO_URL}/${authorUsername}`}
|
||||
userName={authorName || ""}
|
||||
/>
|
||||
<UserName>{authorName}</UserName>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { reduxForm } from "redux-form";
|
|||
import FormGroup from "components/ads/formFields/FormGroup";
|
||||
import FormTextField from "components/ads/formFields/TextField";
|
||||
|
||||
import FormDisplayImage from "./FormDisplayImage";
|
||||
import UserProfileImagePicker from "components/ads/UserProfileImagePicker";
|
||||
|
||||
import { createMessage, DISPLAY_NAME, EMAIL_ADDRESS } from "constants/messages";
|
||||
import styled from "styled-components";
|
||||
|
|
@ -48,7 +48,7 @@ function ProfileForm(props: any) {
|
|||
return (
|
||||
<Container>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<FormDisplayImage />
|
||||
<UserProfileImagePicker />
|
||||
</div>
|
||||
<FormGroup label={createMessage(DISPLAY_NAME)}>
|
||||
<FormTextField
|
||||
|
|
|
|||
|
|
@ -116,7 +116,9 @@ const useUserSuggestions = (
|
|||
setSuggestions: Dispatch<SetStateAction<Array<MentionData>>>,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
setSuggestions(users.map((user) => ({ name: user.username, user })));
|
||||
setSuggestions(
|
||||
users.map((user) => ({ name: user.name || user.username, user })),
|
||||
);
|
||||
}, [users]);
|
||||
};
|
||||
|
||||
|
|
@ -153,8 +155,9 @@ function AddCommentInput({
|
|||
const onSaveComment = useCallback(
|
||||
(editorStateArg?: EditorState) => {
|
||||
const latestEditorState = editorStateArg || editorState;
|
||||
const plainText = latestEditorState.getCurrentContent().getPlainText();
|
||||
|
||||
if (!latestEditorState.getCurrentContent().hasText()) return;
|
||||
if (!plainText || plainText.trim().length === 0) return;
|
||||
|
||||
const contentState = latestEditorState.getCurrentContent();
|
||||
const rawContent = convertToRaw(contentState);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { CommentThread } from "entities/Comments/CommentsInterfaces";
|
||||
import { BUILDER_PAGE_URL } from "constants/routes";
|
||||
import {
|
||||
BUILDER_PAGE_URL,
|
||||
getApplicationViewerPageURL,
|
||||
} from "constants/routes";
|
||||
import { APP_MODE } from "reducers/entityReducers/appReducer";
|
||||
|
||||
// used for dev
|
||||
export const reduceCommentsByRef = (comments: any[]) => {
|
||||
|
|
@ -85,11 +89,13 @@ export const getCommentThreadURL = ({
|
|||
commentThreadId,
|
||||
isResolved,
|
||||
pageId,
|
||||
mode = APP_MODE.PUBLISHED,
|
||||
}: {
|
||||
applicationId?: string;
|
||||
commentThreadId: string;
|
||||
isResolved?: boolean;
|
||||
pageId?: string;
|
||||
mode?: APP_MODE;
|
||||
}) => {
|
||||
const queryParams: Record<string, any> = {
|
||||
commentThreadId,
|
||||
|
|
@ -100,8 +106,13 @@ export const getCommentThreadURL = ({
|
|||
queryParams.isResolved = true;
|
||||
}
|
||||
|
||||
const urlBuilder =
|
||||
mode === APP_MODE.PUBLISHED
|
||||
? getApplicationViewerPageURL
|
||||
: BUILDER_PAGE_URL;
|
||||
|
||||
const url = new URL(
|
||||
`${window.location.origin}${BUILDER_PAGE_URL(
|
||||
`${window.location.origin}${urlBuilder(
|
||||
applicationId,
|
||||
pageId,
|
||||
queryParams,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { getTypographyByKey } from "constants/DefaultTheme";
|
|||
|
||||
import styled from "styled-components";
|
||||
import ImageEditor from "@uppy/image-editor";
|
||||
import { REMOVE, createMessage } from "constants/messages";
|
||||
|
||||
import "@uppy/core/dist/style.css";
|
||||
import "@uppy/dashboard/dist/style.css";
|
||||
|
|
@ -16,6 +17,7 @@ import "@blueprintjs/popover2/lib/css/blueprint-popover2.css";
|
|||
|
||||
type Props = {
|
||||
onChange: (file: File) => void;
|
||||
onRemove?: () => void;
|
||||
submit: (uppy: Uppy.Uppy) => void;
|
||||
value: string;
|
||||
label?: string;
|
||||
|
|
@ -65,7 +67,12 @@ const Container = styled.div`
|
|||
|
||||
const defaultLabel = "Upload Display Picture";
|
||||
|
||||
export default function DisplayImageUpload({ onChange, submit, value }: Props) {
|
||||
export default function DisplayImageUpload({
|
||||
onChange,
|
||||
onRemove,
|
||||
submit,
|
||||
value,
|
||||
}: Props) {
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const uppy = useUppy(() => {
|
||||
|
|
@ -144,6 +151,7 @@ export default function DisplayImageUpload({ onChange, submit, value }: Props) {
|
|||
<Dialog
|
||||
canEscapeKeyClose
|
||||
canOutsideClickClose
|
||||
className="file-picker-dialog"
|
||||
isOpen={isModalOpen}
|
||||
maxHeight={"80vh"}
|
||||
trigger={
|
||||
|
|
@ -164,6 +172,18 @@ export default function DisplayImageUpload({ onChange, submit, value }: Props) {
|
|||
{(!value || loadError) && (
|
||||
<span className="label">{defaultLabel}</span>
|
||||
)}
|
||||
{value && !loadError && (
|
||||
<span
|
||||
className="label"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onRemove) onRemove();
|
||||
}}
|
||||
>
|
||||
{createMessage(REMOVE)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import "@draft-js-plugins/mention/lib/plugin.css";
|
|||
import "draft-js/dist/Draft.css";
|
||||
import { getTypographyByKey } from "constants/DefaultTheme";
|
||||
import { EntryComponentProps } from "@draft-js-plugins/mention/lib/MentionSuggestions/Entry/Entry";
|
||||
import UserApi from "api/UserApi";
|
||||
|
||||
import Icon from "components/ads/Icon";
|
||||
|
||||
import { INVITE_A_NEW_USER, createMessage } from "constants/messages";
|
||||
import { USER_PHOTO_URL } from "constants/userConstants";
|
||||
|
||||
const StyledMention = styled.span`
|
||||
color: ${(props) => props.theme.colors.comments.mention};
|
||||
|
|
@ -103,11 +103,11 @@ function SuggestionComponent(props: EntryComponentProps) {
|
|||
<StyledSuggestionsComponent {...parentProps}>
|
||||
<ProfileImage
|
||||
side={25}
|
||||
source={`/api/${UserApi.photoURL}/${user?.username}`}
|
||||
source={`/api/${USER_PHOTO_URL}/${user?.username}`}
|
||||
userName={user?.username || ""}
|
||||
/>
|
||||
<div>
|
||||
<Name>{user?.username}</Name>
|
||||
<Name>{props.mention.name}</Name>
|
||||
<Username>{user?.username}</Username>
|
||||
</div>
|
||||
</StyledSuggestionsComponent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { updatePhoto } from "actions/userActions";
|
||||
import { updatePhoto, removePhoto } from "actions/userActions";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import DisplayImageUpload from "components/ads/DisplayImageUpload";
|
||||
|
|
@ -33,14 +33,18 @@ function FormDisplayImage() {
|
|||
dispatchActionRef.current(uppy);
|
||||
};
|
||||
|
||||
// TODO implement remove
|
||||
// const removeProfileImage = () => {
|
||||
// dispatch(removePhoto(() => {}));
|
||||
// };
|
||||
const removeProfileImage = () => {
|
||||
dispatch(
|
||||
removePhoto(() => {
|
||||
setImageURL(`/api/${UserApi.photoURL}?${new Date().getTime()}`);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DisplayImageUpload
|
||||
onChange={onSelectFile}
|
||||
onRemove={removeProfileImage}
|
||||
submit={upload}
|
||||
value={imageURL}
|
||||
/>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
56
app/client/src/pages/AppViewer/GlobalHotKeys.tsx
Normal file
56
app/client/src/pages/AppViewer/GlobalHotKeys.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Hotkey, Hotkeys } from "@blueprintjs/core";
|
||||
import { HotkeysTarget } from "@blueprintjs/core/lib/esnext/components/hotkeys/hotkeysTarget.js";
|
||||
import { setCommentMode as setCommentModeAction } from "actions/commentActions";
|
||||
|
||||
import { setCommentModeInUrl } from "pages/Editor/ToggleModeButton";
|
||||
|
||||
type Props = {
|
||||
resetCommentMode: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
@HotkeysTarget
|
||||
class GlobalHotKeys extends React.Component<Props> {
|
||||
public renderHotkeys() {
|
||||
return (
|
||||
<Hotkeys>
|
||||
<Hotkey
|
||||
combo="esc"
|
||||
global
|
||||
group="Canvas"
|
||||
label="Reset"
|
||||
onKeyDown={(e: any) => {
|
||||
this.props.resetCommentMode();
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
<Hotkey
|
||||
combo="v"
|
||||
global
|
||||
label="View Mode"
|
||||
onKeyDown={this.props.resetCommentMode}
|
||||
/>
|
||||
<Hotkey
|
||||
combo="c"
|
||||
global
|
||||
label="Comment Mode"
|
||||
onKeyDown={() => setCommentModeInUrl(true)}
|
||||
/>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
resetCommentMode: () => dispatch(setCommentModeAction(false)),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(null, mapDispatchToProps)(GlobalHotKeys);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { Component } from "react";
|
||||
import styled from "styled-components";
|
||||
import styled, { ThemeProvider } from "styled-components";
|
||||
import { connect } from "react-redux";
|
||||
import { withRouter, RouteComponentProps, Route } from "react-router";
|
||||
import { Switch } from "react-router-dom";
|
||||
|
|
@ -30,6 +30,10 @@ import log from "loglevel";
|
|||
import { getViewModePageList } from "selectors/editorSelectors";
|
||||
import AppComments from "comments/AppComments/AppComments";
|
||||
import AddCommentTourComponent from "comments/tour/AddCommentTourComponent";
|
||||
import CommentShowCaseCarousel from "comments/CommentsShowcaseCarousel";
|
||||
import { getThemeDetails, ThemeMode } from "selectors/themeSelectors";
|
||||
import { Theme } from "constants/DefaultTheme";
|
||||
import GlobalHotKeys from "./GlobalHotKeys";
|
||||
|
||||
const SentryRoute = Sentry.withSentryRouting(Route);
|
||||
|
||||
|
|
@ -47,9 +51,11 @@ const AppViewerBody = styled.section<{ hasPages: boolean }>`
|
|||
const ContainerWithComments = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const AppViewerBodyContainer = styled.div<{ width?: string }>`
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
|
@ -71,6 +77,7 @@ export type AppViewerProps = {
|
|||
) => void;
|
||||
resetChildrenMetaProperty: (widgetId: string) => void;
|
||||
pages: PageListPayload;
|
||||
lightTheme: Theme;
|
||||
} & RouteComponentProps<BuilderRouteParams>;
|
||||
|
||||
class AppViewer extends Component<
|
||||
|
|
@ -98,36 +105,41 @@ class AppViewer extends Component<
|
|||
public render() {
|
||||
const { isInitialized } = this.props;
|
||||
return (
|
||||
<EditorContext.Provider
|
||||
value={{
|
||||
executeAction: this.props.executeAction,
|
||||
updateWidgetMetaProperty: this.props.updateWidgetMetaProperty,
|
||||
resetChildrenMetaProperty: this.props.resetChildrenMetaProperty,
|
||||
}}
|
||||
>
|
||||
<ContainerWithComments>
|
||||
<AppComments isInline />
|
||||
<AppViewerBodyContainer>
|
||||
<AppViewerBody hasPages={this.props.pages.length > 1}>
|
||||
{isInitialized && this.state.registered && (
|
||||
<Switch>
|
||||
<SentryRoute
|
||||
component={AppViewerPageContainer}
|
||||
exact
|
||||
path={getApplicationViewerPageURL()}
|
||||
/>
|
||||
<SentryRoute
|
||||
component={AppViewerPageContainer}
|
||||
exact
|
||||
path={`${getApplicationViewerPageURL()}/fork`}
|
||||
/>
|
||||
</Switch>
|
||||
)}
|
||||
</AppViewerBody>
|
||||
</AppViewerBodyContainer>
|
||||
</ContainerWithComments>
|
||||
<AddCommentTourComponent />
|
||||
</EditorContext.Provider>
|
||||
<ThemeProvider theme={this.props.lightTheme}>
|
||||
<GlobalHotKeys>
|
||||
<EditorContext.Provider
|
||||
value={{
|
||||
executeAction: this.props.executeAction,
|
||||
updateWidgetMetaProperty: this.props.updateWidgetMetaProperty,
|
||||
resetChildrenMetaProperty: this.props.resetChildrenMetaProperty,
|
||||
}}
|
||||
>
|
||||
<ContainerWithComments>
|
||||
<AppComments isInline />
|
||||
<AppViewerBodyContainer>
|
||||
<AppViewerBody hasPages={this.props.pages.length > 1}>
|
||||
{isInitialized && this.state.registered && (
|
||||
<Switch>
|
||||
<SentryRoute
|
||||
component={AppViewerPageContainer}
|
||||
exact
|
||||
path={getApplicationViewerPageURL()}
|
||||
/>
|
||||
<SentryRoute
|
||||
component={AppViewerPageContainer}
|
||||
exact
|
||||
path={`${getApplicationViewerPageURL()}/fork`}
|
||||
/>
|
||||
</Switch>
|
||||
)}
|
||||
</AppViewerBody>
|
||||
</AppViewerBodyContainer>
|
||||
</ContainerWithComments>
|
||||
<AddCommentTourComponent />
|
||||
<CommentShowCaseCarousel />
|
||||
</EditorContext.Provider>
|
||||
</GlobalHotKeys>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -135,6 +147,7 @@ class AppViewer extends Component<
|
|||
const mapStateToProps = (state: AppState) => ({
|
||||
isInitialized: getIsInitialized(state),
|
||||
pages: getViewModePageList(state),
|
||||
lightTheme: getThemeDetails(state, ThemeMode.LIGHT),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => ({
|
||||
|
|
|
|||
|
|
@ -171,9 +171,14 @@ export function AppViewerHeader(props: AppViewerHeaderProps) {
|
|||
<HtmlTitle />
|
||||
<HeaderRow justify={"space-between"}>
|
||||
<HeaderSection justify={"flex-start"}>
|
||||
<PrimaryLogoLink to={APPLICATIONS_URL}>
|
||||
<AppsmithLogoImg alt="Appsmith logo" src={AppsmithLogo} />
|
||||
</PrimaryLogoLink>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PrimaryLogoLink to={APPLICATIONS_URL}>
|
||||
<AppsmithLogoImg alt="Appsmith logo" src={AppsmithLogo} />
|
||||
</PrimaryLogoLink>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ToggleCommentModeButton />
|
||||
</div>
|
||||
</HeaderSection>
|
||||
<HeaderSection className="current-app-name" justify={"center"}>
|
||||
{currentApplicationDetails && (
|
||||
|
|
@ -181,7 +186,6 @@ export function AppViewerHeader(props: AppViewerHeaderProps) {
|
|||
)}
|
||||
</HeaderSection>
|
||||
<HeaderSection justify={"flex-end"}>
|
||||
<ToggleCommentModeButton />
|
||||
{currentApplicationDetails && (
|
||||
<>
|
||||
<FormDialogComponent
|
||||
|
|
|
|||
|
|
@ -78,9 +78,11 @@ import WelcomeHelper from "components/editorComponents/Onboarding/WelcomeHelper"
|
|||
import { useIntiateOnboarding } from "components/editorComponents/Onboarding/utils";
|
||||
import AnalyticsUtil from "utils/AnalyticsUtil";
|
||||
import { createOrganizationSubmitHandler } from "../organization/helpers";
|
||||
import UserApi from "api/UserApi";
|
||||
import ImportApplicationModal from "./ImportApplicationModal";
|
||||
import OnboardingForm from "./OnboardingForm";
|
||||
import { getAppsmithConfigs } from "configs";
|
||||
import { SIGNUP_SUCCESS_URL } from "constants/routes";
|
||||
|
||||
const OrgDropDown = styled.div`
|
||||
display: flex;
|
||||
|
|
@ -769,6 +771,7 @@ function ApplicationsSection(props: any) {
|
|||
<ProfileImage
|
||||
className="org-share-user-icons"
|
||||
key={el.username}
|
||||
source={`/api/${UserApi.photoURL}/${el.username}`}
|
||||
userName={el.name ? el.name : el.username}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -887,13 +890,9 @@ type ApplicationProps = {
|
|||
};
|
||||
|
||||
const getIsFromSignup = () => {
|
||||
if (window.location.href) {
|
||||
const url = new URL(window.location.href);
|
||||
const searchParams = url.searchParams;
|
||||
return !!searchParams.get("isFromSignup");
|
||||
}
|
||||
return false;
|
||||
return window.location?.pathname === SIGNUP_SUCCESS_URL;
|
||||
};
|
||||
|
||||
const { onboardingFormEnabled } = getAppsmithConfigs();
|
||||
class Applications extends Component<
|
||||
ApplicationProps,
|
||||
|
|
@ -912,9 +911,23 @@ class Applications extends Component<
|
|||
PerformanceTracker.stopTracking(PerformanceTransactionName.LOGIN_CLICK);
|
||||
PerformanceTracker.stopTracking(PerformanceTransactionName.SIGN_UP);
|
||||
this.props.getAllApplication();
|
||||
const isFromSignUp = getIsFromSignup();
|
||||
this.setState({
|
||||
showOnboardingForm: getIsFromSignup() && onboardingFormEnabled,
|
||||
showOnboardingForm: isFromSignUp && onboardingFormEnabled,
|
||||
});
|
||||
|
||||
// Redirect directly in case we're not showing the onboarding form
|
||||
if (isFromSignUp && !onboardingFormEnabled) {
|
||||
const urlObject = new URL(window.location.href);
|
||||
const redirectUrl = urlObject?.searchParams.get("redirectUrl");
|
||||
if (redirectUrl) {
|
||||
try {
|
||||
window.location.replace(redirectUrl);
|
||||
} catch (e) {
|
||||
console.error("Error handling the redirect url");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useCallback, useEffect } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import TooltipComponent from "components/ads/Tooltip";
|
||||
import TourTooltipWrapper from "components/ads/tour/TourTooltipWrapper";
|
||||
import { ReactComponent as Pen } from "assets/icons/comments/pen.svg";
|
||||
import { ReactComponent as Eye } from "assets/icons/comments/eye.svg";
|
||||
import { ReactComponent as CommentModeUnread } from "assets/icons/comments/comment-mode-unread-indicator.svg";
|
||||
import { ReactComponent as CommentMode } from "assets/icons/comments/chat.svg";
|
||||
import { Indices } from "constants/Layers";
|
||||
|
|
@ -26,6 +27,10 @@ import { TourType } from "entities/Tour";
|
|||
import useProceedToNextTourStep from "utils/hooks/useProceedToNextTourStep";
|
||||
import { getCommentsIntroSeen } from "utils/storage";
|
||||
import { User } from "constants/userConstants";
|
||||
import { AppState } from "reducers";
|
||||
import { APP_MODE } from "reducers/entityReducers/appReducer";
|
||||
|
||||
import { matchBuilderPath, matchViewerPath } from "constants/routes";
|
||||
|
||||
const ModeButton = styled.div<{ active: boolean }>`
|
||||
position: relative;
|
||||
|
|
@ -117,6 +122,40 @@ export const setCommentModeInUrl = (isCommentMode: boolean) => {
|
|||
});
|
||||
};
|
||||
|
||||
function EditModeReset() {
|
||||
return (
|
||||
<TooltipComponent
|
||||
content={
|
||||
<>
|
||||
Edit Mode
|
||||
<span style={{ color: "#fff", marginLeft: 20 }}>V</span>
|
||||
</>
|
||||
}
|
||||
hoverOpenDelay={1000}
|
||||
position={Position.BOTTOM}
|
||||
>
|
||||
<Pen />
|
||||
</TooltipComponent>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewModeReset() {
|
||||
return (
|
||||
<TooltipComponent
|
||||
content={
|
||||
<>
|
||||
View Mode
|
||||
<span style={{ color: "#fff", marginLeft: 20 }}>V</span>
|
||||
</>
|
||||
}
|
||||
hoverOpenDelay={1000}
|
||||
position={Position.BOTTOM}
|
||||
>
|
||||
<Eye />
|
||||
</TooltipComponent>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleCommentModeButton() {
|
||||
const commentsEnabled = useSelector(areCommentsEnabledForUserAndAppSelector);
|
||||
const isCommentMode = useSelector(commentModeSelector);
|
||||
|
|
@ -129,6 +168,18 @@ function ToggleCommentModeButton() {
|
|||
0,
|
||||
);
|
||||
|
||||
const mode = useSelector((state: AppState) => state.entities.app.mode);
|
||||
|
||||
// Show comment mode button only on the canvas editor and viewer
|
||||
const [shouldHide, setShouldHide] = useState(false);
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
const pathName = window.location.pathname;
|
||||
const shouldShow = matchBuilderPath(pathName) || matchViewerPath(pathName);
|
||||
setShouldHide(!shouldShow);
|
||||
}, [location]);
|
||||
if (shouldHide) return null;
|
||||
|
||||
if (!commentsEnabled) return null;
|
||||
|
||||
const CommentModeIcon = showUnreadIndicator ? CommentModeUnread : CommentMode;
|
||||
|
|
@ -168,18 +219,7 @@ function ToggleCommentModeButton() {
|
|||
active={!isCommentMode}
|
||||
onClick={() => setCommentModeInUrl(false)}
|
||||
>
|
||||
<TooltipComponent
|
||||
content={
|
||||
<>
|
||||
Edit Mode
|
||||
<span style={{ color: "#fff", marginLeft: 20 }}>V</span>
|
||||
</>
|
||||
}
|
||||
hoverOpenDelay={1000}
|
||||
position={Position.BOTTOM}
|
||||
>
|
||||
<Pen />
|
||||
</TooltipComponent>
|
||||
{mode === APP_MODE.EDIT ? <EditModeReset /> : <ViewModeReset />}
|
||||
</ModeButton>
|
||||
<ModeButton
|
||||
active={isCommentMode}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Variant } from "components/ads/common";
|
|||
import { FORGOT_PASSWORD_SUCCESS_TEXT } from "constants/messages";
|
||||
import { logoutUser, updateUserDetails } from "actions/userActions";
|
||||
import { AppState } from "reducers";
|
||||
import UserProfileImagePicker from "components/ads/UserProfileImagePicker";
|
||||
|
||||
const Wrapper = styled.div`
|
||||
& > div {
|
||||
|
|
@ -23,12 +24,6 @@ const FieldWrapper = styled.div`
|
|||
display: flex;
|
||||
`;
|
||||
|
||||
const InputWrapper = styled.div`
|
||||
width: 520px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const LabelWrapper = styled.div`
|
||||
width: 200px;
|
||||
display: flex;
|
||||
|
|
@ -90,21 +85,30 @@ function General() {
|
|||
|
||||
return (
|
||||
<Wrapper>
|
||||
<InputWrapper>
|
||||
<FieldWrapper>
|
||||
<LabelWrapper>
|
||||
<Text type={TextType.H4}>Display Picture</Text>
|
||||
</LabelWrapper>
|
||||
<UserProfileImagePicker />
|
||||
</FieldWrapper>
|
||||
<FieldWrapper>
|
||||
<LabelWrapper>
|
||||
<Text type={TextType.H4}>Display name</Text>
|
||||
</LabelWrapper>
|
||||
{isFetchingUser && <Loader className={Classes.SKELETON} />}
|
||||
{!isFetchingUser && (
|
||||
<TextInput
|
||||
cypressSelector="t--display-name"
|
||||
defaultValue={user?.name}
|
||||
onChange={onNameChange}
|
||||
placeholder="Display name"
|
||||
validator={notEmptyValidator}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
cypressSelector="t--display-name"
|
||||
defaultValue={user?.name}
|
||||
fill={false}
|
||||
onChange={onNameChange}
|
||||
placeholder="Display name"
|
||||
validator={notEmptyValidator}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</InputWrapper>
|
||||
</FieldWrapper>
|
||||
<FieldWrapper>
|
||||
<LabelWrapper>
|
||||
<Text type={TextType.H4}>Email</Text>
|
||||
|
|
@ -118,18 +122,7 @@ function General() {
|
|||
</ForgotPassword>
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
{/* Commenting for now until the image related apis are ready */}
|
||||
{/* <FieldWrapper>
|
||||
<LabelWrapper>
|
||||
<Text type={TextType.H4}>Display Picture</Text>
|
||||
</LabelWrapper>
|
||||
<FilePicker
|
||||
url={""}
|
||||
onFileRemoved={DeleteLogo}
|
||||
logoUploadError={logoUploadError.message}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
<InputWrapper>
|
||||
{/* <InputWrapper>
|
||||
<LabelWrapper>
|
||||
<Text type={TextType.H4}>Website</Text>
|
||||
</LabelWrapper>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { ReduxActionTypes } from "constants/ReduxActionConstants";
|
|||
import ProfileImage from "./ProfileImage";
|
||||
import { PopperModifiers } from "@blueprintjs/core";
|
||||
import { PROFILE } from "constants/routes";
|
||||
import UserApi from "api/UserApi";
|
||||
|
||||
type TagProps = CommonComponentProps & {
|
||||
onClick?: (text: string) => void;
|
||||
|
|
@ -76,7 +77,12 @@ const UserNameWrapper = styled.div`
|
|||
`;
|
||||
|
||||
export default function ProfileDropdown(props: TagProps) {
|
||||
const Profile = <ProfileImage userName={props.userName} />;
|
||||
const Profile = (
|
||||
<ProfileImage
|
||||
source={`/api/${UserApi.photoURL}`}
|
||||
userName={props.name || props.userName}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { getInitialsAndColorCode } from "utils/AppsmithUtils";
|
|||
import ProfileImage from "pages/common/ProfileImage";
|
||||
import ManageUsers from "./ManageUsers";
|
||||
import ScrollIndicator from "components/ads/ScrollIndicator";
|
||||
import UserApi from "api/UserApi";
|
||||
|
||||
const OrgInviteTitle = styled.div`
|
||||
padding: 10px 0px;
|
||||
|
|
@ -343,7 +344,10 @@ function OrgInviteUsersForm(props: any) {
|
|||
<Fragment key={user.username}>
|
||||
<User>
|
||||
<UserInfo>
|
||||
<ProfileImage userName={user.initials} />
|
||||
<ProfileImage
|
||||
source={`/api/${UserApi.photoURL}/${user.username}`}
|
||||
userName={user.name || user.username}
|
||||
/>
|
||||
<UserName>
|
||||
<Text type={TextType.H5}>{user.name}</Text>
|
||||
<Text type={TextType.P2}>{user.username}</Text>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { getCurrentUser } from "selectors/usersSelectors";
|
|||
import { get } from "lodash";
|
||||
|
||||
import { commentModeSelector } from "selectors/commentsSelectors";
|
||||
import { AppState } from "reducers";
|
||||
|
||||
function* createUnpublishedCommentThread(
|
||||
action: ReduxAction<Partial<CreateCommentThreadRequest>>,
|
||||
|
|
@ -57,10 +58,12 @@ function* createCommentThread(action: ReduxAction<CreateCommentThreadPayload>) {
|
|||
);
|
||||
const applicationId = yield select(getCurrentApplicationId);
|
||||
const pageId = yield select(getCurrentPageId);
|
||||
const mode = yield select((state: AppState) => state.entities.app.mode);
|
||||
const response = yield call(CommentsApi.createNewThread, {
|
||||
...newCommentThreadPayload,
|
||||
applicationId,
|
||||
pageId,
|
||||
mode,
|
||||
});
|
||||
const isValidResponse = yield validateResponse(response);
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ export const getSortedAndFilteredAppCommentThreadIds = (
|
|||
shouldShowResolved: boolean,
|
||||
appCommentsFilter: typeof filterOptions[number]["value"],
|
||||
currentUserUsername?: string,
|
||||
currentPageId?: string,
|
||||
): Array<string> => {
|
||||
if (!applicationThreadIds) return [];
|
||||
const result = applicationThreadIds
|
||||
|
|
@ -115,7 +114,6 @@ export const getSortedAndFilteredAppCommentThreadIds = (
|
|||
|
||||
// Happens during delete thread
|
||||
if (!thread) return false;
|
||||
if (thread?.pageId !== currentPageId) return false;
|
||||
|
||||
const isResolved = thread.resolvedState?.active;
|
||||
const isPinned = thread.pinnedState?.active;
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -33,8 +33,13 @@ export enum PerformanceTransactionName {
|
|||
LOGIN_CLICK = "LOGIN_CLICK",
|
||||
INIT_EDIT_APP = "INIT_EDIT_APP",
|
||||
INIT_VIEW_APP = "INIT_VIEW_APP",
|
||||
SHOW_RESIZE_HANDLES = "SHOW_RESIZE_HANDLES",
|
||||
}
|
||||
|
||||
export type PerfTag = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
export interface PerfLog {
|
||||
sentrySpan: Span;
|
||||
skipLog?: boolean;
|
||||
|
|
@ -51,6 +56,7 @@ class PerformanceTracker {
|
|||
eventName: PerformanceTransactionName,
|
||||
data?: any,
|
||||
skipLog = false,
|
||||
tags: Array<PerfTag> = [],
|
||||
) => {
|
||||
if (appsmithConfigs.sentry.enabled) {
|
||||
const currentTransaction = Sentry.getCurrentHub()
|
||||
|
|
@ -78,6 +84,11 @@ class PerformanceTracker {
|
|||
);
|
||||
}
|
||||
const newTransaction = Sentry.startTransaction({ name: eventName });
|
||||
|
||||
tags.forEach(({ name: tagName, value }) => {
|
||||
newTransaction.setTag(tagName, value);
|
||||
});
|
||||
|
||||
newTransaction.setData("startData", data);
|
||||
Sentry.getCurrentHub().configureScope((scope) =>
|
||||
scope.setSpan(newTransaction),
|
||||
|
|
|
|||
|
|
@ -27,5 +27,6 @@
|
|||
<module>redshiftPlugin</module>
|
||||
<module>amazons3Plugin</module>
|
||||
<module>googleSheetsPlugin</module>
|
||||
<module>snowflakePlugin</module>
|
||||
</modules>
|
||||
</project>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -125,6 +125,12 @@ public class PostgresPluginTest {
|
|||
" id timestamptz default now(),\n" +
|
||||
" name timestamptz default now()\n" +
|
||||
")");
|
||||
|
||||
statement.execute("CREATE TABLE jsontest (\n" +
|
||||
" id serial PRIMARY KEY,\n" +
|
||||
" item json,\n" +
|
||||
" origin jsonb" +
|
||||
")");
|
||||
}
|
||||
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
|
|
@ -160,6 +166,14 @@ public class PostgresPluginTest {
|
|||
")");
|
||||
}
|
||||
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute(
|
||||
"INSERT INTO jsontest VALUES (" +
|
||||
"1, '{\"type\":\"racket\", \"manufacturer\":\"butterfly\"}'," +
|
||||
"'{\"country\":\"japan\", \"city\":\"kyoto\"}'"+
|
||||
")");
|
||||
}
|
||||
|
||||
} catch (SQLException throwable) {
|
||||
throwable.printStackTrace();
|
||||
}
|
||||
|
|
@ -336,7 +350,7 @@ public class PostgresPluginTest {
|
|||
StepVerifier.create(structureMono)
|
||||
.assertNext(structure -> {
|
||||
assertNotNull(structure);
|
||||
assertEquals(3, structure.getTables().size());
|
||||
assertEquals(4, structure.getTables().size());
|
||||
|
||||
final DatasourceStructure.Table campusTable = structure.getTables().get(0);
|
||||
assertEquals("public.campus", campusTable.getName());
|
||||
|
|
@ -350,7 +364,21 @@ public class PostgresPluginTest {
|
|||
);
|
||||
assertEquals(campusTable.getKeys().size(), 0);
|
||||
|
||||
final DatasourceStructure.Table possessionsTable = structure.getTables().get(1);
|
||||
final DatasourceStructure.Table jsonTestTable = structure.getTables().get(1);
|
||||
assertEquals("public.jsontest", jsonTestTable.getName());
|
||||
assertEquals(DatasourceStructure.TableType.TABLE, campusTable.getType());
|
||||
assertArrayEquals(
|
||||
new DatasourceStructure.Column[]{
|
||||
new DatasourceStructure.Column("id", "int4", "nextval('jsontest_id_seq" +
|
||||
"'::regclass)"),
|
||||
new DatasourceStructure.Column("item", "json", null),
|
||||
new DatasourceStructure.Column("origin", "jsonb", null)
|
||||
},
|
||||
jsonTestTable.getColumns().toArray()
|
||||
);
|
||||
assertEquals(jsonTestTable.getKeys().size(), 1);
|
||||
|
||||
final DatasourceStructure.Table possessionsTable = structure.getTables().get(2);
|
||||
assertEquals("public.possessions", possessionsTable.getName());
|
||||
assertEquals(DatasourceStructure.TableType.TABLE, possessionsTable.getType());
|
||||
assertArrayEquals(
|
||||
|
|
@ -389,7 +417,7 @@ public class PostgresPluginTest {
|
|||
possessionsTable.getTemplates().toArray()
|
||||
);
|
||||
|
||||
final DatasourceStructure.Table usersTable = structure.getTables().get(2);
|
||||
final DatasourceStructure.Table usersTable = structure.getTables().get(3);
|
||||
assertEquals("public.users", usersTable.getName());
|
||||
assertEquals(DatasourceStructure.TableType.TABLE, usersTable.getType());
|
||||
assertArrayEquals(
|
||||
|
|
@ -1063,4 +1091,28 @@ public class PostgresPluginTest {
|
|||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testJsonTypes() {
|
||||
ActionConfiguration actionConfiguration = new ActionConfiguration();
|
||||
actionConfiguration.setBody("SELECT * FROM jsontest");
|
||||
DatasourceConfiguration dsConfig = createDatasourceConfiguration();
|
||||
Mono<HikariDataSource> connectionPoolMono = pluginExecutor.datasourceCreate(dsConfig);
|
||||
Mono<ActionExecutionResult> resultMono = connectionPoolMono
|
||||
.flatMap(conn -> pluginExecutor.executeParameterized(conn, new ExecuteActionDTO(), dsConfig, actionConfiguration));
|
||||
|
||||
StepVerifier.create(resultMono)
|
||||
.assertNext(result -> {
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getIsExecutionSuccess());
|
||||
assertNotNull(result.getBody());
|
||||
|
||||
final JsonNode node = ((ArrayNode) result.getBody()).get(0);
|
||||
assertEquals("racket", node.get("item").get("type").asText());
|
||||
assertEquals("butterfly", node.get("item").get("manufacturer").asText());
|
||||
assertEquals("japan", node.get("origin").get("country").asText());
|
||||
assertEquals("kyoto", node.get("origin").get("city").asText());
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
236
app/server/appsmith-plugins/snowflakePlugin/pom.xml
Normal file
236
app/server/appsmith-plugins/snowflakePlugin/pom.xml
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.external.plugins</groupId>
|
||||
<artifactId>snowflakePlugin</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<name>snowflakePlugin</name>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<java.version>11</java.version>
|
||||
<maven.compiler.source>${java.version}</maven.compiler.source>
|
||||
<maven.compiler.target>${java.version}</maven.compiler.target>
|
||||
<plugin.id>snowflake-plugin</plugin.id>
|
||||
<plugin.class>com.external.plugins.SnowflakePlugin</plugin.class>
|
||||
<plugin.version>1.0-SNAPSHOT</plugin.version>
|
||||
<plugin.provider>tech@appsmith.com</plugin.provider>
|
||||
<plugin.dependencies/>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.pf4j</groupId>
|
||||
<artifactId>pf4j-spring</artifactId>
|
||||
<version>0.7.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.appsmith</groupId>
|
||||
<artifactId>interfaces</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-core</artifactId>
|
||||
<version>5.2.3.RELEASE</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
<version>5.2.3.RELEASE</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-webflux</artifactId>
|
||||
<version>5.2.3.RELEASE</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-core</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-core</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>net.snowflake</groupId>
|
||||
<artifactId>snowflake-jdbc</artifactId>
|
||||
<version>3.13.4</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.8</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.10.5.1</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jdk8</artifactId>
|
||||
<version>2.10.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>2.10.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
|
||||
<version>0.11.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Test dependencies -->
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.13.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<version>3.3.5.RELEASE</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.13.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/org.powermock/powermock-module-junit4 -->
|
||||
<dependency>
|
||||
<groupId>org.powermock</groupId>
|
||||
<artifactId>powermock-module-junit4</artifactId>
|
||||
<version>2.0.9</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/org.powermock/powermock-api-mockito2 -->
|
||||
<dependency>
|
||||
<groupId>org.powermock</groupId>
|
||||
<artifactId>powermock-api-mockito2</artifactId>
|
||||
<version>2.0.9</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
<version>2.2.4.RELEASE</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<configuration>
|
||||
<minimizeJar>false</minimizeJar>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<manifestEntries>
|
||||
<Plugin-Id>${plugin.id}</Plugin-Id>
|
||||
<Plugin-Class>${plugin.class}</Plugin-Class>
|
||||
<Plugin-Version>${plugin.version}</Plugin-Version>
|
||||
<Plugin-Provider>${plugin.provider}</Plugin-Provider>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-dependencies</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>copy</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/lib</outputDirectory>
|
||||
<artifactItems>
|
||||
<artifactItem>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
</artifactItem>
|
||||
<artifactItem>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
</artifactItem>
|
||||
<artifactItem>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
</artifactItem>
|
||||
</artifactItems>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
264
app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/plugins/SnowflakePlugin.java
vendored
Normal file
264
app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/plugins/SnowflakePlugin.java
vendored
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
package com.external.plugins;
|
||||
|
||||
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
|
||||
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
|
||||
import com.appsmith.external.exceptions.pluginExceptions.StaleConnectionException;
|
||||
import com.appsmith.external.models.ActionConfiguration;
|
||||
import com.appsmith.external.models.ActionExecutionRequest;
|
||||
import com.appsmith.external.models.ActionExecutionResult;
|
||||
import com.appsmith.external.models.DBAuth;
|
||||
import com.appsmith.external.models.DatasourceConfiguration;
|
||||
import com.appsmith.external.models.DatasourceStructure;
|
||||
import com.appsmith.external.models.DatasourceTestResult;
|
||||
import com.appsmith.external.plugins.BasePlugin;
|
||||
import com.appsmith.external.plugins.PluginExecutor;
|
||||
import com.external.utils.SqlUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.pf4j.Extension;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.springframework.util.StringUtils;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
||||
public class SnowflakePlugin extends BasePlugin {
|
||||
|
||||
public SnowflakePlugin(PluginWrapper wrapper) {
|
||||
super(wrapper);
|
||||
}
|
||||
|
||||
@Slf4j
|
||||
@Extension
|
||||
public static class SnowflakePluginExecutor implements PluginExecutor<Connection> {
|
||||
|
||||
private final Scheduler scheduler = Schedulers.elastic();
|
||||
|
||||
@Override
|
||||
public Mono<ActionExecutionResult> execute(Connection connection, DatasourceConfiguration datasourceConfiguration, ActionConfiguration actionConfiguration) {
|
||||
|
||||
String query = actionConfiguration.getBody();
|
||||
|
||||
if (query == null) {
|
||||
return Mono.error(new AppsmithPluginException(
|
||||
AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR,
|
||||
"Missing required parameter: Query."));
|
||||
}
|
||||
|
||||
return Mono
|
||||
.fromCallable(() -> {
|
||||
ResultSet resultSet = null;
|
||||
List<Map<String, Object>> rowsList = new ArrayList<>(50);
|
||||
try {
|
||||
// We do not use keep alive threads for our connections since these might become expensive
|
||||
// Instead for every execution, we check for connection validity,
|
||||
// and reset the connection if required
|
||||
if (!connection.isValid(30)) {
|
||||
throw new StaleConnectionException();
|
||||
}
|
||||
|
||||
Statement statement = connection.createStatement();
|
||||
resultSet = statement.executeQuery(query);
|
||||
ResultSetMetaData metaData = resultSet.getMetaData();
|
||||
int colCount = metaData.getColumnCount();
|
||||
|
||||
while (resultSet.next()) {
|
||||
// Use `LinkedHashMap` here so that the column ordering is preserved in the response.
|
||||
Map<String, Object> row = new LinkedHashMap<>(colCount);
|
||||
|
||||
for (int i = 1; i <= colCount; i++) {
|
||||
Object value = resultSet.getObject(i);
|
||||
row.put(metaData.getColumnName(i), value);
|
||||
}
|
||||
rowsList.add(row);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, e.getMessage());
|
||||
} finally {
|
||||
if (resultSet != null) {
|
||||
try {
|
||||
resultSet.close();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
return rowsList;
|
||||
})
|
||||
.map(rowsList -> {
|
||||
ActionExecutionResult result = new ActionExecutionResult();
|
||||
result.setBody(objectMapper.valueToTree(rowsList));
|
||||
result.setIsExecutionSuccess(true);
|
||||
ActionExecutionRequest request = new ActionExecutionRequest();
|
||||
request.setQuery(query);
|
||||
result.setRequest(request);
|
||||
return result;
|
||||
})
|
||||
.subscribeOn(scheduler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Connection> datasourceCreate(DatasourceConfiguration datasourceConfiguration) {
|
||||
try {
|
||||
Class.forName("net.snowflake.client.jdbc.SnowflakeDriver");
|
||||
} catch (ClassNotFoundException ex) {
|
||||
System.err.println("Driver not found");
|
||||
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, ex.getMessage()));
|
||||
}
|
||||
DBAuth authentication = (DBAuth) datasourceConfiguration.getAuthentication();
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("user", authentication.getUsername());
|
||||
properties.setProperty("password", authentication.getPassword());
|
||||
properties.setProperty("warehouse", String.valueOf(datasourceConfiguration.getProperties().get(0).getValue()));
|
||||
properties.setProperty("db", String.valueOf(datasourceConfiguration.getProperties().get(1).getValue()));
|
||||
|
||||
return Mono
|
||||
.fromCallable(() -> {
|
||||
Connection conn;
|
||||
try {
|
||||
conn = DriverManager.getConnection("jdbc:snowflake://" + datasourceConfiguration.getUrl() + ".snowflakecomputing.com", properties);
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, e.getMessage());
|
||||
}
|
||||
if (conn == null) {
|
||||
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Unable to create connection to Snowflake URL");
|
||||
}
|
||||
return conn;
|
||||
})
|
||||
.subscribeOn(scheduler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void datasourceDestroy(Connection connection) {
|
||||
if (connection != null) {
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException throwable) {
|
||||
throwable.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> validateDatasource(DatasourceConfiguration datasourceConfiguration) {
|
||||
Set<String> invalids = new HashSet<>();
|
||||
|
||||
if (StringUtils.isEmpty(datasourceConfiguration.getUrl())) {
|
||||
invalids.add("Missing Snowflake URL.");
|
||||
}
|
||||
|
||||
if (datasourceConfiguration.getProperties() != null
|
||||
&& (datasourceConfiguration.getProperties().size() < 1
|
||||
|| datasourceConfiguration.getProperties().get(0) == null
|
||||
|| datasourceConfiguration.getProperties().get(0).getValue() == null
|
||||
|| StringUtils.isEmpty(String.valueOf(datasourceConfiguration.getProperties().get(0).getValue())))) {
|
||||
invalids.add("Missing warehouse name.");
|
||||
}
|
||||
|
||||
if (datasourceConfiguration.getProperties() != null
|
||||
&& (datasourceConfiguration.getProperties().size() < 2
|
||||
|| datasourceConfiguration.getProperties().get(1) == null
|
||||
|| datasourceConfiguration.getProperties().get(1).getValue() == null
|
||||
|| StringUtils.isEmpty(String.valueOf(datasourceConfiguration.getProperties().get(1).getValue())))) {
|
||||
invalids.add("Missing database name.");
|
||||
}
|
||||
|
||||
if (datasourceConfiguration.getAuthentication() == null) {
|
||||
invalids.add("Missing authentication details.");
|
||||
} else {
|
||||
DBAuth authentication = (DBAuth) datasourceConfiguration.getAuthentication();
|
||||
if (StringUtils.isEmpty(authentication.getUsername())) {
|
||||
invalids.add("Missing username for authentication.");
|
||||
}
|
||||
|
||||
if (StringUtils.isEmpty(authentication.getPassword())) {
|
||||
invalids.add("Missing password for authentication.");
|
||||
}
|
||||
}
|
||||
|
||||
return invalids;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<DatasourceTestResult> testDatasource(DatasourceConfiguration datasourceConfiguration) {
|
||||
return datasourceCreate(datasourceConfiguration)
|
||||
.flatMap(connection -> {
|
||||
if (connection != null) {
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException throwable) {
|
||||
throwable.printStackTrace();
|
||||
return Mono.error(throwable);
|
||||
}
|
||||
}
|
||||
|
||||
return Mono.just(new DatasourceTestResult());
|
||||
})
|
||||
.onErrorResume(error -> Mono.just(new DatasourceTestResult(error.getMessage())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<DatasourceStructure> getStructure(Connection connection, DatasourceConfiguration datasourceConfiguration) {
|
||||
final DatasourceStructure structure = new DatasourceStructure();
|
||||
final Map<String, DatasourceStructure.Table> tablesByName = new LinkedHashMap<>();
|
||||
final Map<String, DatasourceStructure.Key> keyRegistry = new HashMap<>();
|
||||
|
||||
return Mono
|
||||
.fromSupplier(() -> {
|
||||
try {
|
||||
if (connection.isValid(30)) {
|
||||
Statement statement = connection.createStatement();
|
||||
final String columnsQuery = SqlUtils.COLUMNS_QUERY + "'"
|
||||
+ datasourceConfiguration.getProperties().get(2).getValue() + "'";
|
||||
ResultSet resultSet = statement.executeQuery(columnsQuery);
|
||||
|
||||
while (resultSet.next()) {
|
||||
SqlUtils.getTableInfo(resultSet, tablesByName);
|
||||
}
|
||||
|
||||
resultSet = statement.executeQuery(SqlUtils.PRIMARY_KEYS_QUERY);
|
||||
while (resultSet.next()) {
|
||||
SqlUtils.getPrimaryKeyInfo(resultSet, tablesByName, keyRegistry);
|
||||
}
|
||||
|
||||
resultSet = statement.executeQuery(SqlUtils.FOREIGN_KEYS_QUERY);
|
||||
while (resultSet.next()) {
|
||||
SqlUtils.getForeignKeyInfo(resultSet, tablesByName, keyRegistry);
|
||||
}
|
||||
|
||||
/* Get templates for each table and put those in. */
|
||||
SqlUtils.getTemplates(tablesByName);
|
||||
structure.setTables(new ArrayList<>(tablesByName.values()));
|
||||
for (DatasourceStructure.Table table : structure.getTables()) {
|
||||
table.getKeys().sort(Comparator.naturalOrder());
|
||||
}
|
||||
} else {
|
||||
throw new StaleConnectionException();
|
||||
}
|
||||
} catch (SQLException throwable) {
|
||||
throwable.printStackTrace();
|
||||
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, throwable.getMessage());
|
||||
}
|
||||
return structure;
|
||||
})
|
||||
.subscribeOn(scheduler);
|
||||
}
|
||||
}
|
||||
}
|
||||
235
app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/utils/SqlUtils.java
vendored
Normal file
235
app/server/appsmith-plugins/snowflakePlugin/src/main/java/com/external/utils/SqlUtils.java
vendored
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package com.external.utils;
|
||||
|
||||
import com.appsmith.external.models.DatasourceStructure;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SqlUtils {
|
||||
|
||||
/**
|
||||
* Example output for COLUMNS_QUERY:
|
||||
* +------------+-----------+-------------+-------------+-------------+----------------+
|
||||
* | TABLE_NAME | COLUMN_ID | COLUMN_NAME | COLUMN_TYPE | IS_NULLABLE | COLUMN_DEFAULT |
|
||||
* +------------+-----------+-------------+-------------+-------------+----------------+
|
||||
* | test | 1 | id | int | 0 | |
|
||||
* | test | 2 | firstname | varchar | 1 | Foo |
|
||||
* | test | 3 | middlename | varchar | 1 | |
|
||||
* | test | 4 | lastname | varchar | 1 | |
|
||||
* +------------+-----------+-------------+-------------+-------------+----------------+
|
||||
*/
|
||||
public static final String COLUMNS_QUERY =
|
||||
"SELECT " +
|
||||
"cols.table_name as table_name, " +
|
||||
"cols.ordinal_position as column_id, " +
|
||||
"cols.column_name as column_name, " +
|
||||
"cols.data_type as column_type, " +
|
||||
"cols.is_nullable = 'YES' as is_nullable, " +
|
||||
"cols.column_default as column_default " +
|
||||
"FROM " +
|
||||
"information_schema.columns cols " +
|
||||
"WHERE " +
|
||||
"cols.table_schema = ";
|
||||
|
||||
/**
|
||||
* Example output for PRIMARY_KEYS_QUERY:
|
||||
* +------------+---------------+-------------+------------+-------------+--------------+--------------------+---------+
|
||||
* | created_on | database_name | schema_name | table_name | column_name | key_sequence | constraint_name | comment |
|
||||
* +------------+---------------+-------------+------------+-------------+--------------+--------------------+---------+
|
||||
* | test | test_db | test_schema | test | id | 1 | SYS_CONSTRAINT_hex | |
|
||||
* +------------+---------------+-------------+------------+-------------+--------------+--------------------+---------+
|
||||
*/
|
||||
public static final String PRIMARY_KEYS_QUERY = "SHOW PRIMARY KEYS";
|
||||
|
||||
/**
|
||||
* Example output for FOREIGN_KEYS_QUERY:
|
||||
* +------------+------------------+----------------+---------------+----------------+------------------+----------------+---------------+----------------+--------------+----------------+-------------+--------------------+--------------------+-----------------+------------+
|
||||
* | created_on | pk_database_name | pk_schema_name | pk_table_name | pk_column_name | fk_database_name | fk_schema_name | fk_table_name | pk_column_name | key_sequence | update_rule | delete_rule | fk_name | pk_name | deferrability | comment |
|
||||
* +------------+------------------+----------------+---------------+----------------+------------------+----------------+---------------+----------------+--------------+----------------+-------------+--------------------+--------------------+-----------------+------------+
|
||||
* | test | test_db | test_schema | test | id | test_db | test_schema | test2 | f_id | 1 | NO ACTION | NO ACTION | SYS_CONSTRAINT_hex | SYS_CONSTRAINT_hex | | |
|
||||
* +------------+------------------+----------------+---------------+----------------+------------------+----------------+---------------+----------------+--------------+----------------+-------------+--------------------+--------------------+-----------------+------------+
|
||||
*/
|
||||
public static final String FOREIGN_KEYS_QUERY = "SHOW IMPORTED KEYS";
|
||||
|
||||
public static String getDefaultValueByDataType(String datatype) {
|
||||
if (datatype == null) {
|
||||
return "null";
|
||||
}
|
||||
datatype = datatype.toUpperCase();
|
||||
switch (datatype) {
|
||||
case "NUMBER":
|
||||
case "DECIMAL":
|
||||
case "NUMERIC":
|
||||
case "INTEGER":
|
||||
case "INT":
|
||||
case "BIGINT":
|
||||
case "SMALLINT":
|
||||
return "1";
|
||||
case "FLOAT":
|
||||
case "FLOAT4":
|
||||
case "FLOAT8":
|
||||
case "DOUBLE":
|
||||
case "DOUBLE PRECISION":
|
||||
case "REAL":
|
||||
return "1.0";
|
||||
case "BINARY":
|
||||
case "VARBINARY":
|
||||
return "to_binary('AB')";
|
||||
case "BOOLEAN":
|
||||
return "true";
|
||||
case "DATE":
|
||||
return "2021-01-01";
|
||||
case "TIME":
|
||||
return "00:00:01";
|
||||
case "DATETIME":
|
||||
case "TIMESTAMP":
|
||||
case "TIMESTAMP_LTZ":
|
||||
case "TIMESTAMP_NTZ":
|
||||
case "TIMESTAMP_TZ":
|
||||
return "2021-01-01 00:00:01";
|
||||
case "ARRAY":
|
||||
return "array_construct(1, 2, 3)";
|
||||
case "VARIANT":
|
||||
return
|
||||
"parse_json(' { \"key1\": \"value1\", \"key2\": \"value2\" } ')";
|
||||
case "OBJECT":
|
||||
return
|
||||
"parse_json(' { \"outer_key1\": { \"inner_key1A\": \"1a\", \"inner_key1B\": NULL }, '\n" +
|
||||
" ||\n" +
|
||||
" ' \"outer_key2\": { \"inner_key2\": 2 } '\n" +
|
||||
" ||\n" +
|
||||
" ' } ')";
|
||||
case "GEOGRAPHY":
|
||||
return "'POINT(-122.35 37.55)'";
|
||||
case "VARCHAR":
|
||||
case "CHAR":
|
||||
case "CHARACTER":
|
||||
case "STRING":
|
||||
case "TEXT":
|
||||
default:
|
||||
return "''";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Generate template for all tables in the database.
|
||||
*/
|
||||
public static void getTemplates(Map<String, DatasourceStructure.Table> tablesByName) {
|
||||
for (DatasourceStructure.Table table : tablesByName.values()) {
|
||||
final List<DatasourceStructure.Column> columnsWithoutDefault = table.getColumns()
|
||||
.stream()
|
||||
.filter(column -> column.getDefaultValue() == null)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
final List<String> columnNames = new ArrayList<>();
|
||||
final List<String> columnValues = new ArrayList<>();
|
||||
final StringBuilder setFragments = new StringBuilder();
|
||||
|
||||
for (DatasourceStructure.Column column : columnsWithoutDefault) {
|
||||
final String name = column.getName();
|
||||
final String type = column.getType();
|
||||
String value = getDefaultValueByDataType(type);
|
||||
|
||||
columnNames.add(name);
|
||||
columnValues.add(value);
|
||||
setFragments.append("\n ").append(name).append(" = ").append(value);
|
||||
}
|
||||
|
||||
final String tableName = table.getName();
|
||||
table.getTemplates().addAll(List.of(
|
||||
new DatasourceStructure.Template("SELECT", "SELECT * FROM " + tableName + " LIMIT 10;", null),
|
||||
new DatasourceStructure.Template("INSERT", "INSERT INTO " + tableName
|
||||
+ " (" + String.join(", ", columnNames) + ")\n"
|
||||
+ " VALUES (" + String.join(", ", columnValues) + ");", null),
|
||||
new DatasourceStructure.Template("UPDATE", "UPDATE " + tableName + " SET"
|
||||
+ setFragments.toString() + "\n"
|
||||
+ " WHERE 1 = 0; -- Specify a valid condition here. Removing the condition may update every row in the table!", null),
|
||||
new DatasourceStructure.Template("DELETE", "DELETE FROM " + tableName
|
||||
+ "\n WHERE 1 = 0; -- Specify a valid condition here. Removing the condition may delete everything in the table!", null)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public static void getForeignKeyInfo(ResultSet row, Map<String, DatasourceStructure.Table> tablesByName, Map<String, DatasourceStructure.Key> keyRegistry) throws SQLException {
|
||||
final String constraintName = row.getString("fk_name");
|
||||
final String selfSchema = row.getString("pk_schema_name");
|
||||
final String fkTableName = row.getString("fk_table_name");
|
||||
final String tableName = row.getString("pk_table_name");
|
||||
|
||||
if (!tablesByName.containsKey(tableName)) {
|
||||
/* do nothing */
|
||||
return;
|
||||
}
|
||||
|
||||
final DatasourceStructure.Table table = tablesByName.get(tableName);
|
||||
final String keyFullName = tableName + "." + constraintName;
|
||||
|
||||
final String foreignSchema = row.getString("fk_schema_name");
|
||||
final String prefix = (foreignSchema.equalsIgnoreCase(selfSchema) ? "" : foreignSchema + ".")
|
||||
+ fkTableName + ".";
|
||||
|
||||
if (!keyRegistry.containsKey(keyFullName)) {
|
||||
final DatasourceStructure.ForeignKey key = new DatasourceStructure.ForeignKey(
|
||||
constraintName,
|
||||
new ArrayList<>(),
|
||||
new ArrayList<>()
|
||||
);
|
||||
keyRegistry.put(keyFullName, key);
|
||||
table.getKeys().add(key);
|
||||
}
|
||||
|
||||
((DatasourceStructure.ForeignKey) keyRegistry.get(keyFullName)).getFromColumns()
|
||||
.add(row.getString("pk_column_name"));
|
||||
((DatasourceStructure.ForeignKey) keyRegistry.get(keyFullName)).getToColumns()
|
||||
.add(prefix + row.getString("fk_column_name"));
|
||||
}
|
||||
|
||||
public static void getPrimaryKeyInfo(ResultSet row, Map<String, DatasourceStructure.Table> tablesByName, Map<String, DatasourceStructure.Key> keyRegistry) throws SQLException {
|
||||
final String constraintName = row.getString("constraint_name");
|
||||
final String tableName = row.getString("table_name");
|
||||
|
||||
if (!tablesByName.containsKey(tableName)) {
|
||||
/* do nothing */
|
||||
return;
|
||||
}
|
||||
|
||||
final DatasourceStructure.Table table = tablesByName.get(tableName);
|
||||
final String keyFullName = tableName + "." + constraintName;
|
||||
|
||||
if (!keyRegistry.containsKey(keyFullName)) {
|
||||
final DatasourceStructure.PrimaryKey key = new DatasourceStructure.PrimaryKey(
|
||||
constraintName,
|
||||
new ArrayList<>()
|
||||
);
|
||||
keyRegistry.put(keyFullName, key);
|
||||
table.getKeys().add(key);
|
||||
}
|
||||
((DatasourceStructure.PrimaryKey) keyRegistry.get(keyFullName)).getColumnNames()
|
||||
.add(row.getString("column_name"));
|
||||
}
|
||||
|
||||
public static void getTableInfo(ResultSet row, Map<String, DatasourceStructure.Table> tablesByName) throws SQLException {
|
||||
final String tableName = row.getString("TABLE_NAME");
|
||||
|
||||
if (!tablesByName.containsKey(tableName)) {
|
||||
tablesByName.put(tableName, new DatasourceStructure.Table(
|
||||
DatasourceStructure.TableType.TABLE,
|
||||
tableName,
|
||||
new ArrayList<>(),
|
||||
new ArrayList<>(),
|
||||
new ArrayList<>()
|
||||
));
|
||||
}
|
||||
|
||||
final DatasourceStructure.Table table = tablesByName.get(tableName);
|
||||
table.getColumns().add(new DatasourceStructure.Column(
|
||||
row.getString("COLUMN_NAME"),
|
||||
row.getString("COLUMN_TYPE"),
|
||||
row.getString("COLUMN_DEFAULT")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"editor": [
|
||||
{
|
||||
"sectionName": "",
|
||||
"id": 1,
|
||||
"children": [
|
||||
{
|
||||
"label": "",
|
||||
"internalLabel": "Query",
|
||||
"configProperty": "actionConfiguration.body",
|
||||
"controlType": "QUERY_DYNAMIC_TEXT"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.external.plugins;
|
||||
|
||||
import com.appsmith.external.models.DBAuth;
|
||||
import com.appsmith.external.models.DatasourceConfiguration;
|
||||
import com.appsmith.external.models.Property;
|
||||
import lombok.extern.log4j.Log4j;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@Log4j
|
||||
public class SnowflakePluginTest {
|
||||
|
||||
SnowflakePlugin.SnowflakePluginExecutor pluginExecutor = new SnowflakePlugin.SnowflakePluginExecutor();
|
||||
|
||||
@Test
|
||||
public void testValidateDatasource_InvalidCredentials_returnsInvalids() {
|
||||
DatasourceConfiguration datasourceConfiguration = new DatasourceConfiguration();
|
||||
DBAuth auth = new DBAuth();
|
||||
auth.setUsername(null);
|
||||
auth.setPassword(null);
|
||||
datasourceConfiguration.setAuthentication(auth);
|
||||
datasourceConfiguration.setProperties(List.of(new Property(), new Property()));
|
||||
Set<String> output = pluginExecutor.validateDatasource(datasourceConfiguration);
|
||||
assertTrue(output.contains("Missing username for authentication."));
|
||||
assertTrue(output.contains("Missing password for authentication."));
|
||||
assertTrue(output.contains("Missing Snowflake URL."));
|
||||
assertTrue(output.contains("Missing warehouse name."));
|
||||
assertTrue(output.contains("Missing database name."));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.appsmith.server.authentication.handlers;
|
|||
|
||||
import com.appsmith.server.constants.AnalyticsEvents;
|
||||
import com.appsmith.server.constants.Security;
|
||||
import com.appsmith.server.domains.User;
|
||||
import com.appsmith.server.helpers.RedirectHelper;
|
||||
import com.appsmith.server.services.AnalyticsService;
|
||||
import com.appsmith.server.services.SessionUserService;
|
||||
|
|
@ -21,8 +22,13 @@ import org.springframework.web.server.ServerWebExchange;
|
|||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.appsmith.server.helpers.RedirectHelper.SIGNUP_SUCCESS_URL;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
|
|
@ -59,8 +65,16 @@ public class AuthenticationSuccessHandler implements ServerAuthenticationSuccess
|
|||
) {
|
||||
log.debug("Login succeeded for user: {}", authentication.getPrincipal());
|
||||
|
||||
if (authentication instanceof OAuth2AuthenticationToken) {
|
||||
// In case of OAuth2 based authentication, there is no way to identify if this was a user signup (new user
|
||||
// creation) or if this was a login (existing user). What we do here to identify this, is an approximation.
|
||||
// If and when we find a better way to do identify this, let's please move away from this approximation.
|
||||
// If the user object was created within the last 5 seconds, we treat it as a new user.
|
||||
isFromSignup = ((User) authentication.getPrincipal()).getCreatedAt().isAfter(Instant.now().minusSeconds(5));
|
||||
}
|
||||
|
||||
Mono<Void> redirectionMono = authentication instanceof OAuth2AuthenticationToken
|
||||
? handleOAuth2Redirect(webFilterExchange)
|
||||
? handleOAuth2Redirect(webFilterExchange, isFromSignup)
|
||||
: handleRedirect(webFilterExchange, isFromSignup);
|
||||
|
||||
return sessionUserService.getCurrentUser()
|
||||
|
|
@ -92,23 +106,26 @@ public class AuthenticationSuccessHandler implements ServerAuthenticationSuccess
|
|||
// Disabling this because although the reference in the Javadoc is to a private method, it is still useful.
|
||||
"JavadocReference"
|
||||
)
|
||||
private Mono<Void> handleOAuth2Redirect(WebFilterExchange webFilterExchange) {
|
||||
private Mono<Void> handleOAuth2Redirect(WebFilterExchange webFilterExchange, boolean isFromSignup) {
|
||||
ServerWebExchange exchange = webFilterExchange.getExchange();
|
||||
String state = exchange.getRequest().getQueryParams().getFirst(Security.QUERY_PARAMETER_STATE);
|
||||
String originHeader = RedirectHelper.DEFAULT_REDIRECT_URL;
|
||||
String redirectUrl = RedirectHelper.DEFAULT_REDIRECT_URL;
|
||||
String prefix = Security.STATE_PARAMETER_ORIGIN + "=";
|
||||
if (state != null && !state.isEmpty()) {
|
||||
String[] stateArray = state.split(",");
|
||||
for (int i = 0; i < stateArray.length; i++) {
|
||||
String stateVar = stateArray[i];
|
||||
if (stateVar != null && stateVar.startsWith(Security.STATE_PARAMETER_ORIGIN) && stateVar.contains("=")) {
|
||||
for (String stateVar : stateArray) {
|
||||
if (stateVar != null && stateVar.startsWith(prefix)) {
|
||||
// This is the origin of the request that we want to redirect to
|
||||
originHeader = stateVar.split("=")[1];
|
||||
redirectUrl = stateVar.split("=", 2)[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
URI defaultRedirectLocation = URI.create(originHeader);
|
||||
return this.redirectStrategy.sendRedirect(exchange, defaultRedirectLocation);
|
||||
if (isFromSignup) {
|
||||
redirectUrl = buildSignupSuccessUrl(redirectUrl);
|
||||
}
|
||||
|
||||
return redirectStrategy.sendRedirect(exchange, URI.create(redirectUrl));
|
||||
}
|
||||
|
||||
private Mono<Void> handleRedirect(WebFilterExchange webFilterExchange, boolean isFromSignup) {
|
||||
|
|
@ -120,11 +137,17 @@ public class AuthenticationSuccessHandler implements ServerAuthenticationSuccess
|
|||
.flatMap(redirectHelper::getRedirectUrl)
|
||||
.map(url -> {
|
||||
if (isFromSignup) {
|
||||
url += (url.contains("?") ? "&" : "?") + "isFromSignup=true";
|
||||
// This redirectUrl will be used by the client to redirect after showing a welcome page.
|
||||
url = buildSignupSuccessUrl(url);
|
||||
}
|
||||
return url;
|
||||
})
|
||||
.map(URI::create)
|
||||
.flatMap(redirectUri -> redirectStrategy.sendRedirect(exchange, redirectUri));
|
||||
}
|
||||
|
||||
private String buildSignupSuccessUrl(String redirectUrl) {
|
||||
return SIGNUP_SUCCESS_URL + "?redirectUrl=" + URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,70 @@ package com.appsmith.server.controllers;
|
|||
|
||||
import com.appsmith.server.constants.Url;
|
||||
import com.appsmith.server.domains.Notification;
|
||||
import com.appsmith.server.dtos.ResponseDTO;
|
||||
import com.appsmith.server.dtos.UpdateIsReadNotificationByIdDTO;
|
||||
import com.appsmith.server.dtos.UpdateIsReadNotificationDTO;
|
||||
import com.appsmith.server.exceptions.AppsmithException;
|
||||
import com.appsmith.server.services.NotificationService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
import static com.appsmith.server.exceptions.AppsmithError.UNSUPPORTED_OPERATION;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping(Url.NOTIFICATION_URL)
|
||||
public class NotificationController extends BaseController<NotificationService, Notification, String> {
|
||||
|
||||
@Autowired
|
||||
public NotificationController(NotificationService service) {
|
||||
super(service);
|
||||
}
|
||||
|
||||
@GetMapping("count/unread")
|
||||
public Mono<ResponseDTO<Long>> getUnreadCount() {
|
||||
return service.getUnreadCount()
|
||||
.map(response -> new ResponseDTO<>(HttpStatus.OK.value(), response, null));
|
||||
}
|
||||
|
||||
@PatchMapping("isRead")
|
||||
public Mono<ResponseDTO<UpdateIsReadNotificationByIdDTO>> updateIsRead(
|
||||
@RequestBody @Valid UpdateIsReadNotificationByIdDTO body) {
|
||||
log.debug("Going to set isRead to notifications by id");
|
||||
return service.updateIsRead(body).map(
|
||||
dto -> new ResponseDTO<>(HttpStatus.OK.value(), dto, null, true)
|
||||
);
|
||||
}
|
||||
|
||||
@PatchMapping("isRead/all")
|
||||
public Mono<ResponseDTO<UpdateIsReadNotificationDTO>> updateIsRead(
|
||||
@RequestBody @Valid UpdateIsReadNotificationDTO body) {
|
||||
log.debug("Going to set isRead to all notifications");
|
||||
return service.updateIsRead(body).map(
|
||||
dto -> new ResponseDTO<>(HttpStatus.OK.value(), dto, null, true)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ResponseDTO<Notification>> create(Notification resource, String originHeader, ServerWebExchange exchange) {
|
||||
return Mono.error(new AppsmithException(UNSUPPORTED_OPERATION));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ResponseDTO<Notification>> update(String s, Notification resource) {
|
||||
return Mono.error(new AppsmithException(UNSUPPORTED_OPERATION));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ResponseDTO<Notification>> delete(String s) {
|
||||
return Mono.error(new AppsmithException(UNSUPPORTED_OPERATION));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ public class Comment extends BaseDomain {
|
|||
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
|
||||
String authorUsername;
|
||||
|
||||
private String applicationId;
|
||||
private String applicationName;
|
||||
private String pageId;
|
||||
|
||||
Body body;
|
||||
|
||||
List<Reaction> reactions;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.appsmith.server.domains;
|
|||
|
||||
import com.appsmith.external.models.BaseDomain;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.data.annotation.Transient;
|
||||
|
|
@ -35,9 +36,22 @@ public class CommentThread extends BaseDomain {
|
|||
|
||||
String applicationId;
|
||||
|
||||
String applicationName;
|
||||
|
||||
@JsonIgnore
|
||||
Set<String> viewedByUsers;
|
||||
|
||||
String mode;
|
||||
|
||||
/**
|
||||
* Display name of the user, who authored this comment thread.
|
||||
*/
|
||||
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
|
||||
String authorName;
|
||||
|
||||
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
|
||||
String authorUsername;
|
||||
|
||||
@Transient
|
||||
Boolean isViewed;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.appsmith.server.dtos;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class UpdateIsReadNotificationByIdDTO extends UpdateIsReadNotificationDTO {
|
||||
@NotNull
|
||||
@NotEmpty
|
||||
private List<String> idList;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -2392,16 +2392,16 @@ public class DatabaseChangelog {
|
|||
@ChangeSet(order = "071", id = "add-application-export-permissions", author = "")
|
||||
public void addApplicationExportPermissions(MongoTemplate mongoTemplate) {
|
||||
final List<Organization> organizations = mongoTemplate.find(
|
||||
query(where("userRoles").exists(true)),
|
||||
Organization.class
|
||||
query(where("userRoles").exists(true)),
|
||||
Organization.class
|
||||
);
|
||||
|
||||
for (final Organization organization : organizations) {
|
||||
Set<String> adminUsernames = organization.getUserRoles()
|
||||
.stream()
|
||||
.filter(role -> (role.getRole().equals(AppsmithRole.ORGANIZATION_ADMIN)))
|
||||
.map(role -> role.getUsername())
|
||||
.collect(Collectors.toSet());
|
||||
.stream()
|
||||
.filter(role -> (role.getRole().equals(AppsmithRole.ORGANIZATION_ADMIN)))
|
||||
.map(role -> role.getUsername())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (adminUsernames.isEmpty()) {
|
||||
continue;
|
||||
|
|
@ -2416,7 +2416,7 @@ public class DatabaseChangelog {
|
|||
}
|
||||
|
||||
Optional<Policy> exportAppOrgLevelOptional = policies.stream()
|
||||
.filter(policy -> policy.getPermission().equals(ORGANIZATION_EXPORT_APPLICATIONS.getValue())).findFirst();
|
||||
.filter(policy -> policy.getPermission().equals(ORGANIZATION_EXPORT_APPLICATIONS.getValue())).findFirst();
|
||||
|
||||
if (exportAppOrgLevelOptional.isPresent()) {
|
||||
Policy exportApplicationPolicy = exportAppOrgLevelOptional.get();
|
||||
|
|
@ -2424,7 +2424,7 @@ public class DatabaseChangelog {
|
|||
} else {
|
||||
// this policy doesnt exist. create and add this to the policy set
|
||||
Policy inviteUserPolicy = Policy.builder().permission(ORGANIZATION_EXPORT_APPLICATIONS.getValue())
|
||||
.users(exportApplicationPermissionUsernames).build();
|
||||
.users(exportApplicationPermissionUsernames).build();
|
||||
organization.getPolicies().add(inviteUserPolicy);
|
||||
}
|
||||
|
||||
|
|
@ -2432,8 +2432,8 @@ public class DatabaseChangelog {
|
|||
|
||||
// Update the applications with export applications policy for all administrators of the organization
|
||||
List<Application> orgApplications = mongoTemplate.find(
|
||||
query(where(fieldName(QApplication.application.organizationId)).is(organization.getId())),
|
||||
Application.class
|
||||
query(where(fieldName(QApplication.application.organizationId)).is(organization.getId())),
|
||||
Application.class
|
||||
);
|
||||
|
||||
for (final Application application : orgApplications) {
|
||||
|
|
@ -2443,7 +2443,7 @@ public class DatabaseChangelog {
|
|||
}
|
||||
|
||||
Optional<Policy> exportAppOptional = applicationPolicies.stream()
|
||||
.filter(policy -> policy.getPermission().equals(EXPORT_APPLICATIONS.getValue())).findFirst();
|
||||
.filter(policy -> policy.getPermission().equals(EXPORT_APPLICATIONS.getValue())).findFirst();
|
||||
|
||||
if (exportAppOptional.isPresent()) {
|
||||
Policy exportAppPolicy = exportAppOptional.get();
|
||||
|
|
@ -2451,7 +2451,7 @@ public class DatabaseChangelog {
|
|||
} else {
|
||||
// this policy doesn't exist, create and add this to the policy set
|
||||
Policy newExportAppPolicy = Policy.builder().permission(EXPORT_APPLICATIONS.getValue())
|
||||
.users(adminUsernames).build();
|
||||
.users(adminUsernames).build();
|
||||
application.getPolicies().add(newExportAppPolicy);
|
||||
}
|
||||
|
||||
|
|
@ -2459,4 +2459,26 @@ public class DatabaseChangelog {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ChangeSet(order = "072", id = "add-snowflake-plugin", author = "")
|
||||
public void addSnowflakePlugin(MongoTemplate mongoTemplate) {
|
||||
Plugin plugin = new Plugin();
|
||||
plugin.setName("Snowflake");
|
||||
plugin.setType(PluginType.DB);
|
||||
plugin.setPackageName("snowflake-plugin");
|
||||
plugin.setUiComponent("DbEditorForm");
|
||||
plugin.setDatasourceComponent("AutoForm");
|
||||
plugin.setResponseType(Plugin.ResponseType.TABLE);
|
||||
plugin.setIconLocation("https://s3.us-east-2.amazonaws.com/assets.appsmith.com/Snowflake.png");
|
||||
plugin.setDocumentationLink("https://docs.appsmith.com/datasource-reference/querying-snowflake-db");
|
||||
plugin.setDefaultInstall(true);
|
||||
try {
|
||||
mongoTemplate.insert(plugin);
|
||||
} catch (DuplicateKeyException e) {
|
||||
log.warn(plugin.getPackageName() + " already present in database.");
|
||||
}
|
||||
|
||||
installPluginToAllOrganizations(mongoTemplate, plugin.getId());
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
package com.appsmith.server.repositories;
|
||||
|
||||
import com.appsmith.server.domains.Notification;
|
||||
import com.mongodb.client.result.UpdateResult;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CustomNotificationRepository extends AppsmithRepository<Notification> {
|
||||
Mono<UpdateResult> updateIsReadByForUsernameAndIdList(String forUsername, List<String> idList, boolean isRead);
|
||||
Mono<UpdateResult> updateIsReadByForUsername(String forUsername, boolean isRead);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,42 @@
|
|||
package com.appsmith.server.repositories;
|
||||
|
||||
import com.appsmith.server.domains.Notification;
|
||||
import com.appsmith.server.domains.QNotification;
|
||||
import com.mongodb.client.result.UpdateResult;
|
||||
import org.springframework.data.mongodb.core.ReactiveMongoOperations;
|
||||
import org.springframework.data.mongodb.core.convert.MongoConverter;
|
||||
import org.springframework.data.mongodb.core.query.Update;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class CustomNotificationRepositoryImpl extends BaseAppsmithRepositoryImpl<Notification> implements CustomNotificationRepository {
|
||||
import java.util.List;
|
||||
|
||||
import static org.springframework.data.mongodb.core.query.Criteria.where;
|
||||
import static org.springframework.data.mongodb.core.query.Query.query;
|
||||
|
||||
public class CustomNotificationRepositoryImpl extends BaseAppsmithRepositoryImpl<Notification>
|
||||
implements CustomNotificationRepository {
|
||||
|
||||
public CustomNotificationRepositoryImpl(ReactiveMongoOperations mongoOperations, MongoConverter mongoConverter) {
|
||||
super(mongoOperations, mongoConverter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<UpdateResult> updateIsReadByForUsernameAndIdList(String forUsername, List<String> idList, boolean isRead) {
|
||||
return mongoOperations.updateMulti(
|
||||
query(where(fieldName(QNotification.notification.forUsername)).is(forUsername)
|
||||
.and(fieldName(QNotification.notification.id)).in(idList)
|
||||
),
|
||||
new Update().set(fieldName(QNotification.notification.isRead), isRead),
|
||||
Notification.class
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<UpdateResult> updateIsReadByForUsername(String forUsername, boolean isRead) {
|
||||
return mongoOperations.updateMulti(
|
||||
query(where(fieldName(QNotification.notification.forUsername)).is(forUsername)),
|
||||
new Update().set(fieldName(QNotification.notification.isRead), isRead),
|
||||
Notification.class
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
package com.appsmith.server.repositories;
|
||||
|
||||
import com.appsmith.server.domains.Notification;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Repository
|
||||
public interface NotificationRepository extends BaseRepository<Notification, String>, CustomNotificationRepository {
|
||||
|
||||
Flux<Notification> findByForUsername(String userId);
|
||||
|
||||
Flux<Notification> findByForUsername(String userId, Pageable pageable);
|
||||
Flux<Notification> findByForUsernameAndCreatedAtBefore(String userId, Instant instant, Pageable pageable);
|
||||
Mono<Long> countByForUsername(String userId);
|
||||
Mono<Long> countByForUsernameAndIsReadIsTrue(String userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,9 @@ import com.appsmith.server.acl.PolicyGenerator;
|
|||
import com.appsmith.server.constants.FieldName;
|
||||
import com.appsmith.server.domains.Application;
|
||||
import com.appsmith.server.domains.Comment;
|
||||
import com.appsmith.server.domains.CommentNotification;
|
||||
import com.appsmith.server.domains.CommentThread;
|
||||
import com.appsmith.server.domains.CommentThreadNotification;
|
||||
import com.appsmith.server.domains.Notification;
|
||||
import com.appsmith.server.domains.User;
|
||||
import com.appsmith.server.events.CommentAddedEvent;
|
||||
import com.appsmith.server.events.CommentThreadClosedEvent;
|
||||
import com.appsmith.server.exceptions.AppsmithError;
|
||||
import com.appsmith.server.exceptions.AppsmithException;
|
||||
import com.appsmith.server.helpers.PolicyUtils;
|
||||
|
|
@ -113,6 +109,9 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
|
|||
|
||||
comment.setAuthorId(user.getId());
|
||||
comment.setThreadId(threadId);
|
||||
comment.setApplicationId(thread.getApplicationId());
|
||||
comment.setApplicationName(thread.getApplicationName());
|
||||
comment.setPageId(thread.getPageId());
|
||||
|
||||
final Set<Policy> policies = policyGenerator.getAllChildPolicies(
|
||||
thread.getPolicies(),
|
||||
|
|
@ -148,10 +147,9 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
|
|||
List<Mono<Notification>> notificationMonos = new ArrayList<>();
|
||||
for (String username : usernames) {
|
||||
if (!username.equals(user.getUsername())) {
|
||||
final CommentNotification notification = new CommentNotification();
|
||||
notification.setComment(savedComment);
|
||||
notification.setForUsername(username);
|
||||
Mono<Notification> notificationMono = notificationService.create(notification);
|
||||
Mono<Notification> notificationMono = notificationService.createNotification(
|
||||
savedComment, username
|
||||
);
|
||||
notificationMonos.add(notificationMono);
|
||||
}
|
||||
}
|
||||
|
|
@ -194,6 +192,9 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
|
|||
.flatMap(tuple -> {
|
||||
final User user = tuple.getT1();
|
||||
final Application application = tuple.getT2();
|
||||
commentThread.setApplicationName(application.getName());
|
||||
commentThread.setAuthorName(user.getName());
|
||||
commentThread.setAuthorUsername(user.getUsername());
|
||||
|
||||
final Set<Policy> policies = policyGenerator.getAllChildPolicies(
|
||||
application.getPolicies(),
|
||||
|
|
@ -244,10 +245,7 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
|
|||
List<Mono<Notification>> monos = new ArrayList<>();
|
||||
for (String username : usernames) {
|
||||
if (!username.equals(user.getUsername())) {
|
||||
final CommentThreadNotification notification = new CommentThreadNotification();
|
||||
notification.setCommentThread(commentThread);
|
||||
notification.setForUsername(username);
|
||||
monos.add(notificationService.create(notification));
|
||||
monos.add(notificationService.createNotification(commentThread, username, user));
|
||||
}
|
||||
}
|
||||
return Flux.concat(monos).then(Mono.just(commentThread));
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
package com.appsmith.server.services;
|
||||
|
||||
import com.appsmith.server.domains.Application;
|
||||
import com.appsmith.server.domains.Comment;
|
||||
import com.appsmith.server.domains.CommentThread;
|
||||
import com.appsmith.server.domains.Notification;
|
||||
import com.appsmith.server.domains.Organization;
|
||||
import com.appsmith.server.domains.User;
|
||||
import com.appsmith.server.dtos.UpdateIsReadNotificationByIdDTO;
|
||||
import com.appsmith.server.dtos.UpdateIsReadNotificationDTO;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface NotificationService extends CrudService<Notification, String> {
|
||||
|
||||
Mono<Notification> createNotification(Comment comment, String forUsername);
|
||||
Mono<Notification> createNotification(CommentThread commentThread, String forUsername, User authorUser);
|
||||
Mono<UpdateIsReadNotificationByIdDTO> updateIsRead(UpdateIsReadNotificationByIdDTO dto);
|
||||
Mono<UpdateIsReadNotificationDTO> updateIsRead(UpdateIsReadNotificationDTO dto);
|
||||
Mono<Long> getUnreadCount();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
package com.appsmith.server.services;
|
||||
|
||||
import com.appsmith.server.domains.Comment;
|
||||
import com.appsmith.server.domains.CommentNotification;
|
||||
import com.appsmith.server.domains.CommentThread;
|
||||
import com.appsmith.server.domains.CommentThreadNotification;
|
||||
import com.appsmith.server.domains.Notification;
|
||||
import com.appsmith.server.domains.QNotification;
|
||||
import com.appsmith.server.domains.User;
|
||||
import com.appsmith.server.dtos.UpdateIsReadNotificationByIdDTO;
|
||||
import com.appsmith.server.dtos.UpdateIsReadNotificationDTO;
|
||||
import com.appsmith.server.exceptions.AppsmithError;
|
||||
import com.appsmith.server.exceptions.AppsmithException;
|
||||
import com.appsmith.server.helpers.NumberUtils;
|
||||
import com.appsmith.server.repositories.NotificationRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
|
||||
import org.springframework.data.mongodb.core.convert.MongoConverter;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -13,6 +26,8 @@ import reactor.core.publisher.Mono;
|
|||
import reactor.core.scheduler.Scheduler;
|
||||
|
||||
import javax.validation.Validator;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
|
|
@ -53,7 +68,85 @@ public class NotificationServiceImpl
|
|||
|
||||
@Override
|
||||
public Flux<Notification> get(MultiValueMap<String, String> params) {
|
||||
// results will be sorted in descending order of createdAt
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, QNotification.notification.createdAt.getMetadata().getName());
|
||||
|
||||
// get page size from query params, default is 10 if param not present
|
||||
int pageSize = 10;
|
||||
if(params.containsKey("pageSize")) {
|
||||
String param = params.get("pageSize").get(0);
|
||||
pageSize = NumberUtils.parseInteger(param, 1, 10);
|
||||
}
|
||||
PageRequest pageRequest = PageRequest.of(0, pageSize, sort);
|
||||
|
||||
// get the beforeDate parameter from query param
|
||||
final Instant instant;
|
||||
String paramKey = "beforeDate";
|
||||
if(params.containsKey(paramKey)) {
|
||||
String beforeParam = params.get(paramKey).get(0);
|
||||
try {
|
||||
instant = Instant.parse(beforeParam);
|
||||
} catch (DateTimeParseException e) {
|
||||
return Flux.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "as " + paramKey));
|
||||
}
|
||||
} else {
|
||||
instant = Instant.now(); // param not present, use current time
|
||||
}
|
||||
|
||||
return sessionUserService.getCurrentUser()
|
||||
.flatMapMany(user -> repository.findByForUsername(user.getUsername()));
|
||||
.flatMapMany(
|
||||
user -> repository.findByForUsernameAndCreatedAtBefore(
|
||||
user.getUsername(), instant, pageRequest
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification for the provided comment which is under provided comment thread
|
||||
* @param comment
|
||||
* @param forUsername
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public Mono<Notification> createNotification(Comment comment, String forUsername) {
|
||||
final CommentNotification notification = new CommentNotification();
|
||||
notification.setComment(comment);
|
||||
notification.setForUsername(forUsername);
|
||||
notification.setIsRead(false);
|
||||
return repository.save(notification);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Notification> createNotification(CommentThread commentThread, String forUsername, User authorUser) {
|
||||
final CommentThreadNotification notification = new CommentThreadNotification();
|
||||
notification.setCommentThread(commentThread);
|
||||
notification.setForUsername(forUsername);
|
||||
notification.setIsRead(false);
|
||||
return repository.save(notification);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<UpdateIsReadNotificationByIdDTO> updateIsRead(UpdateIsReadNotificationByIdDTO dto) {
|
||||
return sessionUserService.getCurrentUser()
|
||||
.flatMap(user ->
|
||||
repository.updateIsReadByForUsernameAndIdList(
|
||||
user.getUsername(), dto.getIdList(), dto.getIsRead()
|
||||
).thenReturn(dto)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<UpdateIsReadNotificationDTO> updateIsRead(UpdateIsReadNotificationDTO dto) {
|
||||
return sessionUserService.getCurrentUser()
|
||||
.flatMap(user -> repository.updateIsReadByForUsername(user.getUsername(), dto.getIsRead())
|
||||
.thenReturn(dto)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Long> getUnreadCount() {
|
||||
return sessionUserService.getCurrentUser().flatMap(user ->
|
||||
repository.countByForUsernameAndIsReadIsTrue(user.getUsername())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
package com.appsmith.server.repositories;
|
||||
|
||||
import com.appsmith.server.domains.Notification;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.After;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import reactor.util.function.Tuple2;
|
||||
import reactor.util.function.Tuple3;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest
|
||||
@Slf4j
|
||||
public class CustomNotificationRepositoryImplTest {
|
||||
|
||||
@Autowired
|
||||
private NotificationRepository notificationRepository;
|
||||
|
||||
@After
|
||||
public void afterTest() {
|
||||
notificationRepository.deleteAll();
|
||||
}
|
||||
|
||||
private Notification createNotification(String forUsername, boolean isRead) {
|
||||
Notification notification = new Notification();
|
||||
notification.setForUsername(forUsername);
|
||||
notification.setIsRead(isRead);
|
||||
return notification;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateIsReadByForUsernameAndIdList_WhenUsernameNotMatched_UpdatesNone() {
|
||||
Mono<Notification> saveMono1 = notificationRepository.save(createNotification("abc", false));
|
||||
Mono<Notification> saveMono2 = notificationRepository.save(createNotification("efg", false));
|
||||
|
||||
// create the notifications and then try to update them by different username
|
||||
Mono<Tuple2<Notification, Notification>> tuple2Mono = Mono.zip(saveMono1, saveMono2).flatMap(objects -> {
|
||||
Notification n1 = objects.getT1();
|
||||
Notification n2 = objects.getT2();
|
||||
return notificationRepository.updateIsReadByForUsernameAndIdList(
|
||||
"123", List.of(n1.getId(), n2.getId()), true
|
||||
).thenReturn(objects);
|
||||
});
|
||||
|
||||
// now get the notifications we created
|
||||
Mono<List<Notification>> listMono = tuple2Mono.flatMap(objects -> {
|
||||
Notification n1 = objects.getT1();
|
||||
Notification n2 = objects.getT2();
|
||||
return notificationRepository.findAllById(List.of(n1.getId(), n2.getId())).collectList();
|
||||
});
|
||||
|
||||
// check that fetched notifications have isRead=false
|
||||
StepVerifier.create(listMono).assertNext(notifications -> {
|
||||
Assert.assertEquals(2, notifications.size());
|
||||
Assert.assertEquals(false, notifications.get(0).getIsRead());
|
||||
Assert.assertEquals(false, notifications.get(1).getIsRead());
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateIsReadByForUsernameAndIdList_WhenUsernameMatched_Updated() {
|
||||
Mono<Notification> saveMono1 = notificationRepository.save(createNotification("abc", false));
|
||||
Mono<Notification> saveMono2 = notificationRepository.save(createNotification("abc", false));
|
||||
|
||||
// create the notifications and then try to update them by same username
|
||||
Mono<Tuple2<Notification, Notification>> tuple2Mono = Mono.zip(saveMono1, saveMono2).flatMap(objects -> {
|
||||
Notification n1 = objects.getT1();
|
||||
Notification n2 = objects.getT2();
|
||||
return notificationRepository.updateIsReadByForUsernameAndIdList(
|
||||
"abc", List.of(n1.getId(), n2.getId()), true
|
||||
).thenReturn(objects);
|
||||
});
|
||||
|
||||
// now get the notifications we created
|
||||
Mono<List<Notification>> listMono = tuple2Mono.flatMap(objects -> {
|
||||
Notification n1 = objects.getT1();
|
||||
Notification n2 = objects.getT2();
|
||||
return notificationRepository.findAllById(List.of(n1.getId(), n2.getId())).collectList();
|
||||
});
|
||||
|
||||
// check that fetched notifications have isRead=true
|
||||
StepVerifier.create(listMono).assertNext(notifications -> {
|
||||
Assert.assertEquals(2, notifications.size());
|
||||
Assert.assertEquals(true, notifications.get(0).getIsRead());
|
||||
Assert.assertEquals(true, notifications.get(1).getIsRead());
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateIsReadByForUsernameAndIdList_WhenIdNotMatched_UpdatesNone() {
|
||||
Mono<Notification> saveMono1 = notificationRepository.save(createNotification("abc", false));
|
||||
Mono<Notification> saveMono2 = notificationRepository.save(createNotification("abc", false));
|
||||
|
||||
// create the notifications and then try to update them by different username
|
||||
Mono<Tuple2<Notification, Notification>> tuple2Mono = Mono.zip(saveMono1, saveMono2).flatMap(objects -> {
|
||||
Notification n1 = objects.getT1();
|
||||
Notification n2 = objects.getT2();
|
||||
return notificationRepository.updateIsReadByForUsernameAndIdList(
|
||||
"abc", List.of("test-id-1", "test-id-2"), true
|
||||
).thenReturn(objects);
|
||||
});
|
||||
|
||||
// now get the notifications we created
|
||||
Mono<List<Notification>> listMono = tuple2Mono.flatMap(objects -> {
|
||||
Notification n1 = objects.getT1();
|
||||
Notification n2 = objects.getT2();
|
||||
return notificationRepository.findAllById(List.of(n1.getId(), n2.getId())).collectList();
|
||||
});
|
||||
|
||||
// check that fetched notifications have isRead=true
|
||||
StepVerifier.create(listMono).assertNext(notifications -> {
|
||||
Assert.assertEquals(2, notifications.size());
|
||||
Assert.assertEquals(false, notifications.get(0).getIsRead());
|
||||
Assert.assertEquals(false, notifications.get(1).getIsRead());
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateIsReadByForUsername_WhenForUsernameMatched_UpdatesMatchedOnes() {
|
||||
Mono<Notification> saveMono1 = notificationRepository.save(createNotification("abc", false));
|
||||
Mono<Notification> saveMono2 = notificationRepository.save(createNotification("abc", false));
|
||||
Mono<Notification> saveMono3 = notificationRepository.save(createNotification("efg", false));
|
||||
|
||||
// create the notifications and then try to update them by same username
|
||||
Mono<Tuple3<Notification, Notification, Notification>> tuple2Mono = Mono.zip(
|
||||
saveMono1, saveMono2, saveMono3
|
||||
).flatMap(objects ->
|
||||
notificationRepository.updateIsReadByForUsername("abc",true).thenReturn(objects)
|
||||
);
|
||||
|
||||
// now get the notifications we created
|
||||
Mono<Map<String, Collection<Notification>>> mapMono = tuple2Mono.flatMap(objects -> {
|
||||
Notification n1 = objects.getT1();
|
||||
Notification n2 = objects.getT2();
|
||||
Notification n3 = objects.getT3();
|
||||
return notificationRepository.findAllById(
|
||||
List.of(n1.getId(), n2.getId(), n3.getId())
|
||||
).collectMultimap(Notification::getForUsername);
|
||||
});
|
||||
|
||||
// check that fetched notifications have isRead=true
|
||||
StepVerifier.create(mapMono).assertNext(notificationCollectionMap -> {
|
||||
Assert.assertEquals(2, notificationCollectionMap.size()); // should contain map of two keys
|
||||
|
||||
Notification forEfg = notificationCollectionMap.get("efg").iterator().next();
|
||||
Assert.assertEquals(false, forEfg.getIsRead()); // this should be still unread
|
||||
|
||||
notificationCollectionMap.get("abc").iterator().forEachRemaining(notification -> {
|
||||
Assert.assertEquals(true, notification.getIsRead());
|
||||
});
|
||||
}).verifyComplete();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package com.appsmith.server.services;
|
||||
|
||||
import com.appsmith.server.domains.Notification;
|
||||
import com.appsmith.server.domains.User;
|
||||
import com.appsmith.server.dtos.UpdateIsReadNotificationByIdDTO;
|
||||
import com.appsmith.server.dtos.UpdateIsReadNotificationDTO;
|
||||
import com.appsmith.server.exceptions.AppsmithError;
|
||||
import com.appsmith.server.repositories.NotificationRepository;
|
||||
import com.mongodb.client.result.UpdateResult;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
|
||||
import org.springframework.data.mongodb.core.convert.MongoConverter;
|
||||
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import javax.validation.Validator;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
@RunWith(SpringJUnit4ClassRunner.class)
|
||||
public class NotificationServiceImplTest {
|
||||
@MockBean
|
||||
private Scheduler scheduler;
|
||||
@MockBean
|
||||
private Validator validator;
|
||||
@MockBean
|
||||
private MongoConverter mongoConverter;
|
||||
@MockBean
|
||||
private ReactiveMongoTemplate reactiveMongoTemplate;
|
||||
@MockBean
|
||||
private NotificationRepository repository;
|
||||
@MockBean
|
||||
private AnalyticsService analyticsService;
|
||||
@MockBean
|
||||
private SessionUserService sessionUserService;
|
||||
|
||||
NotificationService notificationService;
|
||||
private User currentUser;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
notificationService = new NotificationServiceImpl(
|
||||
scheduler, validator, mongoConverter, reactiveMongoTemplate,
|
||||
repository, analyticsService, sessionUserService
|
||||
);
|
||||
currentUser = new User();
|
||||
currentUser.setEmail("sample-email");
|
||||
|
||||
Mockito.when(sessionUserService.getCurrentUser()).thenReturn(Mono.just(currentUser));
|
||||
// mock the repository to return count as 100
|
||||
Mockito.when(repository.countByForUsername(currentUser.getUsername())).thenReturn(Mono.just(100L));
|
||||
// mock the repository to return unread count as 5
|
||||
Mockito.when(repository.countByForUsernameAndIsReadIsTrue(currentUser.getUsername())).thenReturn(Mono.just(5L));
|
||||
}
|
||||
|
||||
private List<Notification> createSampleNotificationList() {
|
||||
// create some sample notification
|
||||
List<Notification> notificationList = new ArrayList<>();
|
||||
for(int i = 1; i <= 5; i++) {
|
||||
Notification notification = new Notification();
|
||||
notification.setId("test-id-" + i);
|
||||
notificationList.add(notification);
|
||||
}
|
||||
return notificationList;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void get_WhenNoBeforeParamProvided_ReturnsData() {
|
||||
List<Notification> notificationList = createSampleNotificationList();
|
||||
|
||||
// mock the repository to return the sample list of notification when called with current time
|
||||
Mockito.when(repository.findByForUsernameAndCreatedAtBefore(
|
||||
eq(currentUser.getUsername()), Mockito.any(Instant.class), Mockito.any(Pageable.class))
|
||||
).thenReturn(Flux.fromIterable(notificationList));
|
||||
|
||||
Flux<Notification> notificationFlux = notificationService.get(new LinkedMultiValueMap<>());
|
||||
StepVerifier
|
||||
.create(notificationFlux.collectList())
|
||||
.assertNext(listResponseDTO -> {
|
||||
assertThat(listResponseDTO.size()).isEqualTo(notificationList.size());
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void get_WhenValidBeforeParamExists_ReturnsData() {
|
||||
List<Notification> notificationList = createSampleNotificationList();
|
||||
|
||||
Instant instant = Instant.now();
|
||||
|
||||
// mock the repository to return the sample list of notification
|
||||
Mockito.when(repository.findByForUsernameAndCreatedAtBefore(
|
||||
eq(currentUser.getUsername()), eq(instant), Mockito.any(Pageable.class))
|
||||
).thenReturn(Flux.fromIterable(notificationList));
|
||||
|
||||
// add the sample pagination parameters
|
||||
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
|
||||
params.put("beforeDate", List.of(instant.toString()));
|
||||
|
||||
Flux<Notification> notificationFlux = notificationService.get(params);
|
||||
|
||||
StepVerifier
|
||||
.create(notificationFlux.collectList())
|
||||
.assertNext(listResponseDTO -> {
|
||||
assertThat(listResponseDTO.size()).isEqualTo(notificationList.size());
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void get_WhenInvalidValidBeforeParam_ThrowsException() {
|
||||
// add the sample pagination parameters
|
||||
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
|
||||
params.put("beforeDate", List.of("abcd"));
|
||||
|
||||
Flux<Notification> notificationFlux = notificationService.get(params);
|
||||
|
||||
StepVerifier
|
||||
.create(notificationFlux.collectList())
|
||||
.expectErrorMessage(AppsmithError.INVALID_PARAMETER.getMessage("as beforeDate"))
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateIsRead_WhenUpdateAll_ReturnSuccessfully() {
|
||||
UpdateIsReadNotificationDTO dto = new UpdateIsReadNotificationDTO();
|
||||
dto.setIsRead(true);
|
||||
|
||||
Mockito.when(repository.updateIsReadByForUsername(currentUser.getUsername(), true)).thenReturn(
|
||||
Mono.just(Mockito.mock(UpdateResult.class))
|
||||
);
|
||||
|
||||
StepVerifier
|
||||
.create(notificationService.updateIsRead(dto))
|
||||
.assertNext(responseDTO -> {
|
||||
assertThat(responseDTO.getIsRead()).isTrue();
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateIsRead_WhenUpdateById_ReturnSuccessfully() {
|
||||
UpdateIsReadNotificationByIdDTO dto = new UpdateIsReadNotificationByIdDTO();
|
||||
dto.setIsRead(true);
|
||||
dto.setIdList(List.of("sample-id-1", "sample-id-2", "sample-id-3"));
|
||||
|
||||
Mockito.when(repository.updateIsReadByForUsernameAndIdList(
|
||||
currentUser.getUsername(), dto.getIdList(), true)
|
||||
).thenReturn(
|
||||
Mono.just(Mockito.mock(UpdateResult.class))
|
||||
);
|
||||
|
||||
StepVerifier
|
||||
.create(notificationService.updateIsRead(dto))
|
||||
.assertNext(responseDTO -> {
|
||||
assertThat(responseDTO.getIsRead()).isTrue();
|
||||
assertThat(responseDTO.getIdList()).isEqualTo(dto.getIdList());
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user