Filepicker component and logo upload for org (#250)

This commit is contained in:
satbir121 2020-12-02 03:31:27 +05:30 committed by GitHub
parent 5d820c7203
commit 8afa900044
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 578 additions and 8 deletions

View File

@ -1,5 +1,5 @@
import { ReduxActionTypes } from "constants/ReduxActionConstants"; import { ReduxActionTypes } from "constants/ReduxActionConstants";
import { SaveOrgRequest } from "api/OrgApi"; import { SaveOrgLogo, SaveOrgRequest } from "api/OrgApi";
export const fetchOrg = (orgId: string) => { export const fetchOrg = (orgId: string) => {
return { return {
@ -66,3 +66,19 @@ export const saveOrg = (orgSettings: SaveOrgRequest) => {
payload: orgSettings, 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,
},
};
};

View File

@ -52,6 +52,12 @@ export interface SaveOrgRequest {
email?: string; email?: string;
} }
export interface SaveOrgLogo {
id: string;
logo: File;
progress: (progressEvent: ProgressEvent) => void;
}
export interface CreateOrgRequest { export interface CreateOrgRequest {
name: string; name: string;
} }
@ -100,5 +106,26 @@ class OrgApi extends Api {
roleName: null, roleName: null,
}); });
} }
static saveOrgLogo(request: SaveOrgLogo): AxiosPromise<ApiResponse> {
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<ApiResponse> {
return Api.delete(OrgApi.orgsURL + "/" + request.id + "/logo");
}
} }
export default OrgApi; export default OrgApi;

View File

@ -0,0 +1,3 @@
<svg width="42" height="29" viewBox="0 0 42 29" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.3636 29H28.6364H23.8636V21.2667H27.6818L21 13.5333L14.3182 21.2667H18.1364V29H13.3636H7.63636C3.41918 29 0 25.5374 0 21.2667C0 17.6552 2.457 14.645 5.76164 13.7963C6.12818 6.11707 12.3728 0 20.0455 0C27.6322 0 33.8253 5.9798 34.3159 13.5391C38.598 13.5082 42 17.0191 42 21.2667C42 25.5374 38.5808 29 34.3636 29Z" fill="#9F9F9F"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

View File

@ -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<boolean>(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<HTMLInputElement>(null);
const bgRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const fileDescRef = useRef<HTMLDivElement>(null);
const fileContainerRef = useRef<HTMLDivElement>(null);
function ButtonClick(event: React.MouseEvent<HTMLElement>) {
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 (
<ContainerDiv
isActive={isActive}
canDrop={canDrop}
isUploaded={isUploaded}
ref={drop}
>
<div ref={bgRef} className="bg-image">
<div className="button-wrapper" ref={fileContainerRef}>
<UploadIcon />
<Text type={TextType.P2} className="drag-drop-text">
Drag & Drop files to upload or
</Text>
<form>
<input
type="file"
id="fileInput"
multiple={false}
ref={inputRef}
accept=".jpeg,.png,.svg"
value={""}
onChange={el => handleFileUpload(el.target.files)}
/>
<Button
text="Browse"
category={Category.tertiary}
size={Size.medium}
onClick={el => ButtonClick(el)}
/>
</form>
</div>
<div className="file-description" ref={fileDescRef} id="fileDesc">
<div className="file-spec">
<Text type={TextType.H6}>{fileInfo.name}</Text>
<Text type={TextType.H6}>{fileInfo.size}KB</Text>
</div>
<div className="progress-container">
<div className="progress-inner" ref={progressRef}></div>
</div>
</div>
</div>
<div className="remove-button">
<Button
text="remove"
icon="delete"
size={Size.medium}
category={Category.tertiary}
onClick={el => removeFile()}
/>
</div>
</ContainerDiv>
);
};
const FilePicker = (props: FilePickerProps) => {
return (
<DndProvider backend={HTML5Backend}>
<FilePickerComponent {...props} />
</DndProvider>
);
};
export default FilePicker;

View File

@ -81,7 +81,7 @@ const boxStyles = (
const StyledInput = styled.input< const StyledInput = styled.input<
TextInputProps & { inputStyle: boxReturnType; isValid: boolean } TextInputProps & { inputStyle: boxReturnType; isValid: boolean }
>` >`
width: ${props => (props.fill ? "100%" : "260px")}; width: ${props => (props.fill ? "100%" : "320px")};
border-radius: 0; border-radius: 0;
outline: 0; outline: 0;
box-shadow: none; box-shadow: none;

View File

@ -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 = () => (
<FilePicker
onFileUploaded={data => ShowUploadedFile(data)}
fileUploader={CloudinaryUploader}
/>
);

View File

@ -690,6 +690,15 @@ type ColorType = {
light: ShadeColor; light: ShadeColor;
dark: ShadeColor; dark: ShadeColor;
}; };
filePicker: {
bg: ShadeColor;
color: ShadeColor;
progress: ShadeColor;
shadow: {
from: string;
to: string;
};
};
formFooter: { formFooter: {
cancelBtn: ShadeColor; cancelBtn: ShadeColor;
}; };
@ -838,7 +847,7 @@ export const dark: ColorType = {
border: darkShades[2], border: darkShades[2],
}, },
normal: { normal: {
bg: darkShades[0], bg: lightShades[10],
text: darkShades[9], text: darkShades[9],
border: darkShades[0], border: darkShades[0],
}, },
@ -967,6 +976,15 @@ export const dark: ColorType = {
light: darkShades[2], light: darkShades[2],
dark: darkShades[4], 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: { formFooter: {
cancelBtn: darkShades[9], cancelBtn: darkShades[9],
}, },
@ -1244,6 +1262,15 @@ export const light: ColorType = {
light: lightShades[2], light: lightShades[2],
dark: lightShades[4], 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: { formFooter: {
cancelBtn: lightShades[9], cancelBtn: lightShades[9],
}, },
@ -1259,7 +1286,7 @@ export const light: ColorType = {
export const theme: Theme = { export const theme: Theme = {
radii: [0, 4, 8, 10, 20, 50], radii: [0, 4, 8, 10, 20, 50],
fontSizes: [0, 10, 12, 14, 16, 18, 24, 28, 32, 48, 64], 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], fontWeights: [0, 400, 500, 700],
typography: { typography: {
h1: { h1: {

View File

@ -148,6 +148,8 @@ export const ReduxActionTypes: { [key: string]: string } = {
FETCH_ORGS_INIT: "FETCH_ORGS_INIT", FETCH_ORGS_INIT: "FETCH_ORGS_INIT",
SAVE_ORG_INIT: "SAVE_ORG_INIT", SAVE_ORG_INIT: "SAVE_ORG_INIT",
SAVE_ORG_SUCCESS: "SAVE_ORG_SUCCESS", SAVE_ORG_SUCCESS: "SAVE_ORG_SUCCESS",
UPLOAD_ORG_LOGO: "UPLOAD_ORG_LOGO",
REMOVE_ORG_LOGO: "REMOVE_ORG_LOGO",
SAVING_ORG_INFO: "SAVING_ORG_INFO", SAVING_ORG_INFO: "SAVING_ORG_INFO",
SET_CURRENT_ORG: "SET_CURRENT_ORG", SET_CURRENT_ORG: "SET_CURRENT_ORG",
SET_CURRENT_ORG_ID: "SET_CURRENT_ORG_ID", SET_CURRENT_ORG_ID: "SET_CURRENT_ORG_ID",

View File

@ -12,6 +12,8 @@ export type Org = {
name: string; name: string;
website?: string; website?: string;
email?: string; email?: string;
logoUrl?: string;
uploadProgress?: number;
userPermissions?: string[]; userPermissions?: string[];
}; };

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { saveOrg } from "actions/orgActions"; import { deleteOrgLogo, saveOrg, uploadOrgLogo } from "actions/orgActions";
import { SaveOrgRequest } from "api/OrgApi"; import { SaveOrgRequest } from "api/OrgApi";
import { debounce } from "lodash"; import { debounce } from "lodash";
import TextInput, { import TextInput, {
@ -8,11 +8,19 @@ import TextInput, {
notEmptyValidator, notEmptyValidator,
} from "components/ads/TextInput"; } from "components/ads/TextInput";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { getCurrentOrg } from "selectors/organizationSelectors"; import {
getCurrentError,
getCurrentOrg,
} from "selectors/organizationSelectors";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import Text, { TextType } from "components/ads/Text"; import Text, { TextType } from "components/ads/Text";
import { Classes } from "@blueprintjs/core"; import { Classes } from "@blueprintjs/core";
import { getOrgLoadingStates } from "selectors/organizationSelectors";
import FilePicker, {
SetProgress,
UploadCallback,
} from "components/ads/FilePicker";
import { getIsFetchingApplications } from "selectors/applicationSelectors"; import { getIsFetchingApplications } from "selectors/applicationSelectors";
const InputLabelWrapper = styled.div` const InputLabelWrapper = styled.div`
@ -36,7 +44,13 @@ export const SettingsHeading = styled(Text)`
const Loader = styled.div` const Loader = styled.div`
height: 38px; height: 38px;
width: 260px; width: 320px;
border-radius: 0;
`;
const FilePickerLoader = styled.div`
height: 190px;
width: 333px;
border-radius: 0; border-radius: 0;
`; `;
@ -73,6 +87,36 @@ export function GeneralSettings() {
}); });
}, timeout); }, 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); const isFetchingApplications = useSelector(getIsFetchingApplications);
return ( return (
@ -96,6 +140,23 @@ export function GeneralSettings() {
)} )}
</SettingWrapper> </SettingWrapper>
<SettingWrapper>
<InputLabelWrapper>
<Text type={TextType.H4}>Upload Logo</Text>
</InputLabelWrapper>
{isFetchingOrg && (
<FilePickerLoader className={Classes.SKELETON}></FilePickerLoader>
)}
{!isFetchingOrg && (
<FilePicker
url={currentOrg && currentOrg.logoUrl}
fileUploader={FileUploader}
onFileRemoved={DeleteLogo}
logoUploadError={logoUploadError.message}
/>
)}
</SettingWrapper>
<SettingWrapper> <SettingWrapper>
<InputLabelWrapper> <InputLabelWrapper>
<Text type={TextType.H4}>Website</Text> <Text type={TextType.H4}>Website</Text>

View File

@ -1,4 +1,4 @@
import { call, takeLatest, put, all } from "redux-saga/effects"; import { call, takeLatest, put, all, select } from "redux-saga/effects";
import { import {
ReduxActionTypes, ReduxActionTypes,
ReduxAction, ReduxAction,
@ -22,10 +22,12 @@ import OrgApi, {
DeleteOrgUserRequest, DeleteOrgUserRequest,
ChangeUserRoleRequest, ChangeUserRoleRequest,
FetchAllRolesRequest, FetchAllRolesRequest,
SaveOrgLogo,
} from "api/OrgApi"; } from "api/OrgApi";
import { ApiResponse } from "api/ApiResponses"; import { ApiResponse } from "api/ApiResponses";
import { Toaster } from "components/ads/Toast"; import { Toaster } from "components/ads/Toast";
import { Variant } from "components/ads/common"; import { Variant } from "components/ads/common";
import { getCurrentOrg } from "selectors/organizationSelectors";
export function* fetchRolesSaga() { export function* fetchRolesSaga() {
try { try {
@ -229,6 +231,60 @@ export function* createOrgSaga(
} }
} }
export function* uploadOrgLogoSaga(action: ReduxAction<SaveOrgLogo>) {
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() { export default function* orgSagas() {
yield all([ yield all([
takeLatest(ReduxActionTypes.FETCH_ORG_ROLES_INIT, fetchRolesSaga), takeLatest(ReduxActionTypes.FETCH_ORG_ROLES_INIT, fetchRolesSaga),
@ -242,5 +298,7 @@ export default function* orgSagas() {
ReduxActionTypes.CHANGE_ORG_USER_ROLE_INIT, ReduxActionTypes.CHANGE_ORG_USER_ROLE_INIT,
changeOrgUserRoleSaga, changeOrgUserRoleSaga,
), ),
takeLatest(ReduxActionTypes.UPLOAD_ORG_LOGO, uploadOrgLogoSaga),
takeLatest(ReduxActionTypes.REMOVE_ORG_LOGO, deleteOrgLogoSaga),
]); ]);
} }

View File

@ -58,3 +58,6 @@ export const getRolesForField = createSelector(getAllRoles, (roles?: any) => {
export const getDefaultRole = createSelector(getRoles, (roles?: OrgRole[]) => { export const getDefaultRole = createSelector(getRoles, (roles?: OrgRole[]) => {
return roles?.find(role => role.isDefault); return roles?.find(role => role.isDefault);
}); });
export const getCurrentError = (state: AppState) => {
return state.ui.errors;
};