diff --git a/app/client/src/actions/orgActions.ts b/app/client/src/actions/orgActions.ts index e18d723cbe..bae3b69e16 100644 --- a/app/client/src/actions/orgActions.ts +++ b/app/client/src/actions/orgActions.ts @@ -1,5 +1,5 @@ import { ReduxActionTypes } from "constants/ReduxActionConstants"; -import { SaveOrgRequest } from "api/OrgApi"; +import { SaveOrgLogo, SaveOrgRequest } from "api/OrgApi"; export const fetchOrg = (orgId: string) => { return { @@ -66,3 +66,19 @@ export const saveOrg = (orgSettings: SaveOrgRequest) => { payload: orgSettings, }; }; + +export const uploadOrgLogo = (orgLogo: SaveOrgLogo) => { + return { + type: ReduxActionTypes.UPLOAD_ORG_LOGO, + payload: orgLogo, + }; +}; + +export const deleteOrgLogo = (id: string) => { + return { + type: ReduxActionTypes.REMOVE_ORG_LOGO, + payload: { + id: id, + }, + }; +}; diff --git a/app/client/src/api/OrgApi.ts b/app/client/src/api/OrgApi.ts index 8ea68790e5..03e7dc5bdc 100644 --- a/app/client/src/api/OrgApi.ts +++ b/app/client/src/api/OrgApi.ts @@ -52,6 +52,12 @@ export interface SaveOrgRequest { email?: string; } +export interface SaveOrgLogo { + id: string; + logo: File; + progress: (progressEvent: ProgressEvent) => void; +} + export interface CreateOrgRequest { name: string; } @@ -100,5 +106,26 @@ class OrgApi extends Api { roleName: null, }); } + static saveOrgLogo(request: SaveOrgLogo): AxiosPromise { + const formData = new FormData(); + if (request.logo) { + formData.append("file", request.logo); + } + + return Api.post( + OrgApi.orgsURL + "/" + request.id + "/logo", + formData, + null, + { + headers: { + "Content-Type": "multipart/form-data", + }, + onUploadProgress: request.progress, + }, + ); + } + static deleteOrgLogo(request: { id: string }): AxiosPromise { + return Api.delete(OrgApi.orgsURL + "/" + request.id + "/logo"); + } } export default OrgApi; diff --git a/app/client/src/assets/icons/ads/upload.svg b/app/client/src/assets/icons/ads/upload.svg new file mode 100644 index 0000000000..923eff9b5f --- /dev/null +++ b/app/client/src/assets/icons/ads/upload.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/components/ads/FilePicker.tsx b/app/client/src/components/ads/FilePicker.tsx new file mode 100644 index 0000000000..087be95f5d --- /dev/null +++ b/app/client/src/components/ads/FilePicker.tsx @@ -0,0 +1,353 @@ +import React, { useEffect, useRef, useState } from "react"; +import styled from "styled-components"; +import Button, { Category, Size } from "./Button"; +import axios from "axios"; +import { ReactComponent as UploadIcon } from "../../assets/icons/ads/upload.svg"; +import { DndProvider, useDrop, DropTargetMonitor } from "react-dnd"; +import HTML5Backend, { NativeTypes } from "react-dnd-html5-backend"; +import Text, { TextType } from "./Text"; +import { Classes, Variant } from "./common"; +import { Toaster } from "./Toast"; + +const CLOUDINARY_PRESETS_NAME = ""; +const CLOUDINARY_CLOUD_NAME = ""; + +type FilePickerProps = { + onFileUploaded?: (fileUrl: string) => void; + onFileRemoved?: () => void; + fileUploader?: FileUploader; + url?: string; + logoUploadError?: string; +}; + +const ContainerDiv = styled.div<{ + isUploaded: boolean; + isActive: boolean; + canDrop: boolean; +}>` + width: 320px; + height: 190px; + background-color: ${props => props.theme.colors.filePicker.bg}; + position: relative; + + #fileInput { + display: none; + } + + .drag-drop-text { + margin: ${props => props.theme.spaces[6]}px 0 + ${props => props.theme.spaces[6]}px 0; + color: ${props => props.theme.colors.filePicker.color}; + } + + .bg-image { + width: 100%; + height: 100%; + display: grid; + place-items: center; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + } + + .file-description { + width: 95%; + margin-top: auto; + margin-bottom: ${props => props.theme.spaces[6] + 1}px; + display: none; + } + + .file-spec { + margin-bottom: ${props => props.theme.spaces[2]}px; + span { + margin-right: ${props => props.theme.spaces[4]}px; + } + } + + .progress-container { + width: 100%; + background: ${props => props.theme.colors.filePicker.progress}; + transition: height 0.2s; + } + + .progress-inner { + background-color: ${props => props.theme.colors.success.light}; + transition: width 0.4s ease; + height: ${props => props.theme.spaces[1]}px; + border-radius: ${props => props.theme.spaces[1] - 1}px; + width: 0%; + } + + .button-wrapper { + display: flex; + flex-direction: column; + align-items: center; + } + + .remove-button { + display: none; + position: absolute; + bottom: 0; + right: 0; + background: linear-gradient( + 180deg, + ${props => props.theme.colors.filePicker.shadow.from}, + ${props => props.theme.colors.filePicker.shadow.to} + ); + opacity: 0.6; + width: 100%; + + a { + width: 110px; + margin: ${props => props.theme.spaces[13]}px + ${props => props.theme.spaces[3]}px ${props => props.theme.spaces[3]}px + auto; + .${Classes.ICON} { + margin-right: ${props => props.theme.spaces[2] - 1}px; + } + } + } + + &:hover { + .remove-button { + display: ${props => (props.isUploaded ? "block" : "none")}; + } + } +`; + +export type SetProgress = (percentage: number) => void; +export type UploadCallback = (url: string) => void; +export type FileUploader = ( + file: any, + setProgress: SetProgress, + onUpload: UploadCallback, +) => void; + +export function CloudinaryUploader( + file: any, + setProgress: SetProgress, + onUpload: UploadCallback, +) { + const formData = new FormData(); + formData.append("upload_preset", CLOUDINARY_PRESETS_NAME); + if (file) { + formData.append("file", file); + } + axios + .post( + `https://api.cloudinary.com/v1_1/${CLOUDINARY_CLOUD_NAME}/image/upload`, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + onUploadProgress: function(progressEvent: ProgressEvent) { + const uploadPercentage = Math.round( + (progressEvent.loaded / progressEvent.total) * 100, + ); + setProgress(uploadPercentage); + }, + }, + ) + .then(data => { + onUpload(data.data.url); + }) + .catch(error => { + console.error("error in file uploading", error); + }); +} + +const FilePickerComponent = (props: FilePickerProps) => { + const { logoUploadError } = props; + const [fileInfo, setFileInfo] = useState<{ name: string; size: number }>({ + name: "", + size: 0, + }); + const [isUploaded, setIsUploaded] = useState(false); + const [fileUrl, setFileUrl] = useState(""); + + const [{ canDrop, isOver }, drop] = useDrop({ + accept: [NativeTypes.FILE], + drop(item, monitor) { + onDrop(monitor); + }, + collect: monitor => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); + + const inputRef = useRef(null); + const bgRef = useRef(null); + const progressRef = useRef(null); + const fileDescRef = useRef(null); + const fileContainerRef = useRef(null); + + function ButtonClick(event: React.MouseEvent) { + event.preventDefault(); + if (inputRef.current) { + inputRef.current.click(); + } + } + + function onDrop(monitor: DropTargetMonitor) { + if (monitor) { + const files = monitor.getItem().files; + if (files) { + handleFileUpload(files); + } + } + } + + function setProgress(uploadPercentage: number) { + if (progressRef.current) { + progressRef.current.style.width = `${uploadPercentage}%`; + } + if (uploadPercentage === 100) { + setIsUploaded(true); + if (fileDescRef.current && bgRef.current) { + fileDescRef.current.style.display = "none"; + bgRef.current.style.opacity = "1"; + } + } + } + + function onUpload(url: string) { + props.onFileUploaded && props.onFileUploaded(url); + } + + function handleFileUpload(files: FileList | null) { + const file = files && files[0]; + let fileSize = 0; + + if (file) { + fileSize = Math.floor(file.size / 1024); + setFileInfo({ name: file.name, size: fileSize }); + } + + if (fileSize < 250) { + if (bgRef.current) { + bgRef.current.style.backgroundImage = `url(${URL.createObjectURL( + file, + )})`; + bgRef.current.style.opacity = "0.5"; + } + if (fileDescRef.current) { + fileDescRef.current.style.display = "block"; + } + if (fileContainerRef.current) { + fileContainerRef.current.style.display = "none"; + } + + /* set form data and send api request */ + props.fileUploader && props.fileUploader(file, setProgress, onUpload); + } else { + Toaster.show({ + text: "File size should be less than 250kb!", + variant: Variant.warning, + }); + } + } + + function removeFile() { + if (fileContainerRef.current && bgRef.current) { + setFileUrl(""); + fileContainerRef.current.style.display = "flex"; + bgRef.current.style.backgroundImage = "url('')"; + setIsUploaded(false); + props.onFileRemoved && props.onFileRemoved(); + } + } + + const isActive = canDrop && isOver; + + useEffect(() => { + if (props.url) { + const urlKeys = props.url.split("/"); + if (urlKeys[urlKeys.length - 1] !== "null") { + setFileUrl(props.url); + } else { + setFileUrl(""); + } + } + }, [props.url]); + + useEffect(() => { + if (fileUrl && !isUploaded) { + setIsUploaded(true); + if (bgRef.current) { + bgRef.current.style.backgroundImage = `url(${fileUrl})`; + bgRef.current.style.opacity = "1"; + } + if (fileDescRef.current) { + fileDescRef.current.style.display = "none"; + } + if (fileContainerRef.current) { + fileContainerRef.current.style.display = "none"; + } + } + }, [fileUrl, logoUploadError]); + + return ( + +
+
+ + + Drag & Drop files to upload or + +
+ handleFileUpload(el.target.files)} + /> +
+
+
+ {fileInfo.name} + {fileInfo.size}KB +
+
+
+
+
+
+
+
+
+ ); +}; + +const FilePicker = (props: FilePickerProps) => { + return ( + + + + ); +}; + +export default FilePicker; diff --git a/app/client/src/components/ads/TextInput.tsx b/app/client/src/components/ads/TextInput.tsx index 50d0c7e537..41c946d70a 100644 --- a/app/client/src/components/ads/TextInput.tsx +++ b/app/client/src/components/ads/TextInput.tsx @@ -81,7 +81,7 @@ const boxStyles = ( const StyledInput = styled.input< TextInputProps & { inputStyle: boxReturnType; isValid: boolean } >` - width: ${props => (props.fill ? "100%" : "260px")}; + width: ${props => (props.fill ? "100%" : "320px")}; border-radius: 0; outline: 0; box-shadow: none; diff --git a/app/client/src/components/stories/FilePicker.stories.tsx b/app/client/src/components/stories/FilePicker.stories.tsx new file mode 100644 index 0000000000..c218b28d21 --- /dev/null +++ b/app/client/src/components/stories/FilePicker.stories.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import FilePicker, { CloudinaryUploader } from "../ads/FilePicker"; + +export default { + title: "FilePicker", + component: FilePicker, +}; + +function ShowUploadedFile(data: any) { + console.log(data); +} + +export const withDynamicProps = () => ( + ShowUploadedFile(data)} + fileUploader={CloudinaryUploader} + /> +); diff --git a/app/client/src/constants/DefaultTheme.tsx b/app/client/src/constants/DefaultTheme.tsx index f5d4d2c731..30dea55fe3 100644 --- a/app/client/src/constants/DefaultTheme.tsx +++ b/app/client/src/constants/DefaultTheme.tsx @@ -690,6 +690,15 @@ type ColorType = { light: ShadeColor; dark: ShadeColor; }; + filePicker: { + bg: ShadeColor; + color: ShadeColor; + progress: ShadeColor; + shadow: { + from: string; + to: string; + }; + }; formFooter: { cancelBtn: ShadeColor; }; @@ -838,7 +847,7 @@ export const dark: ColorType = { border: darkShades[2], }, normal: { - bg: darkShades[0], + bg: lightShades[10], text: darkShades[9], border: darkShades[0], }, @@ -967,6 +976,15 @@ export const dark: ColorType = { light: darkShades[2], dark: darkShades[4], }, + filePicker: { + bg: darkShades[1], + color: darkShades[7], + progress: darkShades[6], + shadow: { + from: "rgba(21, 17, 17, 0.0001)", + to: "rgba(9, 7, 7, 0.883386)", + }, + }, formFooter: { cancelBtn: darkShades[9], }, @@ -1244,6 +1262,15 @@ export const light: ColorType = { light: lightShades[2], dark: lightShades[4], }, + filePicker: { + bg: lightShades[2], + color: lightShades[7], + progress: lightShades[6], + shadow: { + from: "rgba(253, 253, 253, 0.0001)", + to: "rgba(250, 250, 250, 0.898847)", + }, + }, formFooter: { cancelBtn: lightShades[9], }, @@ -1259,7 +1286,7 @@ export const light: ColorType = { export const theme: Theme = { radii: [0, 4, 8, 10, 20, 50], fontSizes: [0, 10, 12, 14, 16, 18, 24, 28, 32, 48, 64], - spaces: [0, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 30, 36], + spaces: [0, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 30, 36, 38, 40, 42, 44], fontWeights: [0, 400, 500, 700], typography: { h1: { diff --git a/app/client/src/constants/ReduxActionConstants.tsx b/app/client/src/constants/ReduxActionConstants.tsx index a7c980ac19..bae6305b37 100644 --- a/app/client/src/constants/ReduxActionConstants.tsx +++ b/app/client/src/constants/ReduxActionConstants.tsx @@ -148,6 +148,8 @@ export const ReduxActionTypes: { [key: string]: string } = { FETCH_ORGS_INIT: "FETCH_ORGS_INIT", SAVE_ORG_INIT: "SAVE_ORG_INIT", SAVE_ORG_SUCCESS: "SAVE_ORG_SUCCESS", + UPLOAD_ORG_LOGO: "UPLOAD_ORG_LOGO", + REMOVE_ORG_LOGO: "REMOVE_ORG_LOGO", SAVING_ORG_INFO: "SAVING_ORG_INFO", SET_CURRENT_ORG: "SET_CURRENT_ORG", SET_CURRENT_ORG_ID: "SET_CURRENT_ORG_ID", diff --git a/app/client/src/constants/orgConstants.ts b/app/client/src/constants/orgConstants.ts index f8da847646..93402a4662 100644 --- a/app/client/src/constants/orgConstants.ts +++ b/app/client/src/constants/orgConstants.ts @@ -12,6 +12,8 @@ export type Org = { name: string; website?: string; email?: string; + logoUrl?: string; + uploadProgress?: number; userPermissions?: string[]; }; diff --git a/app/client/src/pages/organization/General.tsx b/app/client/src/pages/organization/General.tsx index 0f622f6ade..bbc37eac54 100644 --- a/app/client/src/pages/organization/General.tsx +++ b/app/client/src/pages/organization/General.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { saveOrg } from "actions/orgActions"; +import { deleteOrgLogo, saveOrg, uploadOrgLogo } from "actions/orgActions"; import { SaveOrgRequest } from "api/OrgApi"; import { debounce } from "lodash"; import TextInput, { @@ -8,11 +8,19 @@ import TextInput, { notEmptyValidator, } from "components/ads/TextInput"; import { useSelector, useDispatch } from "react-redux"; -import { getCurrentOrg } from "selectors/organizationSelectors"; +import { + getCurrentError, + getCurrentOrg, +} from "selectors/organizationSelectors"; import { useParams } from "react-router-dom"; import styled from "styled-components"; import Text, { TextType } from "components/ads/Text"; import { Classes } from "@blueprintjs/core"; +import { getOrgLoadingStates } from "selectors/organizationSelectors"; +import FilePicker, { + SetProgress, + UploadCallback, +} from "components/ads/FilePicker"; import { getIsFetchingApplications } from "selectors/applicationSelectors"; const InputLabelWrapper = styled.div` @@ -36,7 +44,13 @@ export const SettingsHeading = styled(Text)` const Loader = styled.div` height: 38px; - width: 260px; + width: 320px; + border-radius: 0; +`; + +const FilePickerLoader = styled.div` + height: 190px; + width: 333px; border-radius: 0; `; @@ -73,6 +87,36 @@ export function GeneralSettings() { }); }, timeout); + const { isFetchingOrg } = useSelector(getOrgLoadingStates); + const logoUploadError = useSelector(getCurrentError); + + const FileUploader = ( + file: File, + setProgress: SetProgress, + onUpload: UploadCallback, + ) => { + const progress = (progressEvent: ProgressEvent) => { + const uploadPercentage = Math.round( + (progressEvent.loaded / progressEvent.total) * 100, + ); + if (uploadPercentage === 100) { + onUpload(currentOrg.logoUrl || ""); + } + setProgress(uploadPercentage); + }; + + dispatch( + uploadOrgLogo({ + id: orgId as string, + logo: file, + progress: progress, + }), + ); + }; + + const DeleteLogo = () => { + dispatch(deleteOrgLogo(orgId)); + }; const isFetchingApplications = useSelector(getIsFetchingApplications); return ( @@ -96,6 +140,23 @@ export function GeneralSettings() { )} + + + Upload Logo + + {isFetchingOrg && ( + + )} + {!isFetchingOrg && ( + + )} + + Website diff --git a/app/client/src/sagas/OrgSagas.ts b/app/client/src/sagas/OrgSagas.ts index 2ea992a57b..0c4bbb3cd2 100644 --- a/app/client/src/sagas/OrgSagas.ts +++ b/app/client/src/sagas/OrgSagas.ts @@ -1,4 +1,4 @@ -import { call, takeLatest, put, all } from "redux-saga/effects"; +import { call, takeLatest, put, all, select } from "redux-saga/effects"; import { ReduxActionTypes, ReduxAction, @@ -22,10 +22,12 @@ import OrgApi, { DeleteOrgUserRequest, ChangeUserRoleRequest, FetchAllRolesRequest, + SaveOrgLogo, } from "api/OrgApi"; import { ApiResponse } from "api/ApiResponses"; import { Toaster } from "components/ads/Toast"; import { Variant } from "components/ads/common"; +import { getCurrentOrg } from "selectors/organizationSelectors"; export function* fetchRolesSaga() { try { @@ -229,6 +231,60 @@ export function* createOrgSaga( } } +export function* uploadOrgLogoSaga(action: ReduxAction) { + try { + const request = action.payload; + const response: ApiResponse = yield call(OrgApi.saveOrgLogo, request); + const isValidResponse = yield validateResponse(response); + if (isValidResponse) { + const currentOrg = yield select(getCurrentOrg); + if (currentOrg && currentOrg.id === request.id) { + const updatedOrg = { + ...currentOrg, + logoUrl: response.data.logoUrl, + }; + yield put({ + type: ReduxActionTypes.SET_CURRENT_ORG, + payload: updatedOrg, + }); + Toaster.show({ + text: "Logo uploaded successfully", + variant: Variant.success, + }); + } + } + } catch (error) { + console.log("Error occured while uploading the logo", error); + } +} + +export function* deleteOrgLogoSaga(action: ReduxAction<{ id: string }>) { + try { + const request = action.payload; + const response: ApiResponse = yield call(OrgApi.deleteOrgLogo, request); + const isValidResponse = yield validateResponse(response); + if (isValidResponse) { + const currentOrg = yield select(getCurrentOrg); + if (currentOrg && currentOrg.id === request.id) { + const updatedOrg = { + ...currentOrg, + logoUrl: response.data.logoUrl, + }; + yield put({ + type: ReduxActionTypes.SET_CURRENT_ORG, + payload: updatedOrg, + }); + Toaster.show({ + text: "Logo removed successfully", + variant: Variant.success, + }); + } + } + } catch (error) { + console.log("Error occured while removing the logo", error); + } +} + export default function* orgSagas() { yield all([ takeLatest(ReduxActionTypes.FETCH_ORG_ROLES_INIT, fetchRolesSaga), @@ -242,5 +298,7 @@ export default function* orgSagas() { ReduxActionTypes.CHANGE_ORG_USER_ROLE_INIT, changeOrgUserRoleSaga, ), + takeLatest(ReduxActionTypes.UPLOAD_ORG_LOGO, uploadOrgLogoSaga), + takeLatest(ReduxActionTypes.REMOVE_ORG_LOGO, deleteOrgLogoSaga), ]); } diff --git a/app/client/src/selectors/organizationSelectors.tsx b/app/client/src/selectors/organizationSelectors.tsx index af58f1e4a0..5dfc60fb04 100644 --- a/app/client/src/selectors/organizationSelectors.tsx +++ b/app/client/src/selectors/organizationSelectors.tsx @@ -58,3 +58,6 @@ export const getRolesForField = createSelector(getAllRoles, (roles?: any) => { export const getDefaultRole = createSelector(getRoles, (roles?: OrgRole[]) => { return roles?.find(role => role.isDefault); }); +export const getCurrentError = (state: AppState) => { + return state.ui.errors; +};