mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 21:30:31 +00:00
feat: wip UI upload attachments
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
|
||||
.popover :global .bp4-popover-content{
|
||||
min-width: 450px;
|
||||
}
|
||||
|
||||
|
||||
.attachmentButton:not([class*=bp4-intent-]) {
|
||||
&,
|
||||
&:hover{
|
||||
background-color: #fff;
|
||||
}
|
||||
border: 1px solid rgb(206, 212, 218);
|
||||
}
|
||||
|
||||
.attachmentField :global .bp4-label{
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// @ts-nocheck
|
||||
import clsx from 'classnames';
|
||||
import { Field, useFormikContext } from 'formik';
|
||||
import {
|
||||
Button,
|
||||
Classes,
|
||||
Popover,
|
||||
PopoverInteractionKind,
|
||||
} from '@blueprintjs/core';
|
||||
import { FFormGroup } from '@/components';
|
||||
import { UploadAttachmentsPopoverContent } from './UploadAttachmentsPopoverContent';
|
||||
import { transformToCamelCase, transfromToSnakeCase } from '@/utils';
|
||||
import styles from './UploadAttachmentButton.module.scss';
|
||||
|
||||
function UploadAttachmentButtonButtonContentField() {
|
||||
return (
|
||||
<Field name={'attachments'}>
|
||||
{({ form: { setFieldValue }, field: { value } }) => (
|
||||
<UploadAttachmentsPopoverContent
|
||||
value={transformToCamelCase(value)}
|
||||
onChange={(changedValue) => {
|
||||
setFieldValue('attachments', transfromToSnakeCase(changedValue));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadAttachmentButton() {
|
||||
const { values } = useFormikContext();
|
||||
const uploadedFiles = values?.attachments?.length || 0;
|
||||
|
||||
return (
|
||||
<FFormGroup
|
||||
name={'attachments'}
|
||||
label={'Attachments'}
|
||||
className={styles.attachmentField}
|
||||
fastField={true}
|
||||
>
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.CLICK}
|
||||
popoverClassName={clsx(styles.popover, Classes.POPOVER_CONTENT_SIZING)}
|
||||
placement={'top-start'}
|
||||
content={<UploadAttachmentButtonButtonContentField />}
|
||||
>
|
||||
<Button className={styles.attachmentButton}>
|
||||
{uploadedFiles > 0 ? (
|
||||
<>Upload attachments ({uploadedFiles})</>
|
||||
) : (
|
||||
<>Upload attachments</>
|
||||
)}
|
||||
</Button>
|
||||
</Popover>
|
||||
</FFormGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
.content {
|
||||
}
|
||||
|
||||
.hintText{
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
color: #8F99A8;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.attachments{
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.attachmentItem{
|
||||
border: 1px solid #D3D8DE;
|
||||
border-radius: 3px;
|
||||
padding: 6px 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.attachmentFilenameText{
|
||||
color: #404854;
|
||||
}
|
||||
|
||||
.attachmentSizeText{
|
||||
font-size: 12px;
|
||||
color: #738091;
|
||||
}
|
||||
|
||||
.attachmentContent{
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.attachmentIcon{
|
||||
color: #8F99A8;
|
||||
}
|
||||
|
||||
.label{
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dropzoneRoot{
|
||||
min-height: 140px;
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// @ts-nocheck
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Button, Intent, Text, Spinner } from '@blueprintjs/core';
|
||||
import { Box, Group, Icon, Stack } from '@/components';
|
||||
import {
|
||||
ImportDropzoneField,
|
||||
ImportDropzoneFieldProps,
|
||||
} from '@/containers/Import/ImportDropzoneFile';
|
||||
import { useUncontrolled } from '@/hooks/useUncontrolled';
|
||||
import { useUploadAttachments } from '@/hooks/query/attachments';
|
||||
import { formatBytes } from '../Sales/Invoices/InvoiceForm/utils';
|
||||
import styles from './UploadAttachmentPopoverContent.module.scss';
|
||||
import { MIME_TYPES } from '@/components/Dropzone/mine-types';
|
||||
|
||||
interface AttachmentFileCommon {
|
||||
originName: string;
|
||||
key: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
}
|
||||
interface AttachmentFileLoaded extends AttachmentFileCommon {}
|
||||
interface AttachmentFileLoading extends AttachmentFileCommon {
|
||||
_loading: boolean;
|
||||
}
|
||||
type AttachmentFile = AttachmentFileLoaded | AttachmentFileLoading;
|
||||
|
||||
interface UploadAttachmentsPopoverContentProps {
|
||||
initialValue?: AttachmentFile[];
|
||||
value?: AttachmentFile[];
|
||||
onChange?: (value: AttachmentFile[]) => void;
|
||||
onUploadedChange?: (value: AttachmentFile[]) => void;
|
||||
dropzoneFieldProps?: ImportDropzoneFieldProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads and list the attachments with ability to delete particular attachment.
|
||||
* @param {UploadAttachmentsPopoverContentProps}
|
||||
*/
|
||||
export function UploadAttachmentsPopoverContent({
|
||||
initialValue,
|
||||
value,
|
||||
onChange,
|
||||
onUploadedChange,
|
||||
dropzoneFieldProps,
|
||||
}: UploadAttachmentsPopoverContentProps) {
|
||||
// Controlled/uncontrolled value state.
|
||||
const [localFiles, handleFilesChange] = useUncontrolled<AttachmentFile[]>({
|
||||
finalValue: [],
|
||||
initialValue,
|
||||
value,
|
||||
onChange: onChange,
|
||||
});
|
||||
// Stops loading of the given attachment key and updates it to new key,
|
||||
// that came from the server-side after uploading is done.
|
||||
const stopLoadingAttachment = (
|
||||
localFiles: AttachmentFile[],
|
||||
internalKey: string,
|
||||
newKey: string,
|
||||
) => {
|
||||
return localFiles.map((localFile) => {
|
||||
if (localFile.key === internalKey) {
|
||||
return {
|
||||
...localFile,
|
||||
key: newKey,
|
||||
_loading: false,
|
||||
};
|
||||
}
|
||||
return localFile;
|
||||
});
|
||||
};
|
||||
// Uploads the attachments.
|
||||
const { mutateAsync: uploadAttachments } = useUploadAttachments({
|
||||
onSuccess: (data) => {
|
||||
const newLocalFiles = stopLoadingAttachment(
|
||||
localFiles,
|
||||
data.config.data.get('internalKey'),
|
||||
data.data.data.key,
|
||||
);
|
||||
handleFilesChange(newLocalFiles);
|
||||
onUploadedChange && onUploadedChange(newLocalFiles);
|
||||
},
|
||||
});
|
||||
// Deletes the attachment of the given file key.
|
||||
const handleClick = (key: string) => () => {
|
||||
const updatedFiles = localFiles.filter((file, i) => file.key !== key);
|
||||
handleFilesChange(updatedFiles);
|
||||
onUploadedChange && onUploadedChange(updatedFiles);
|
||||
};
|
||||
|
||||
// Handle change dropzone.
|
||||
const handleChangeDropzone = (file: File) => {
|
||||
const formData = new FormData();
|
||||
const key = Date.now().toString();
|
||||
|
||||
formData.append('file', file);
|
||||
formData.append('internalKey', key);
|
||||
|
||||
handleFilesChange([
|
||||
{
|
||||
originName: file.name,
|
||||
size: file.size,
|
||||
key,
|
||||
_loading: true,
|
||||
},
|
||||
...localFiles,
|
||||
]);
|
||||
uploadAttachments(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div>
|
||||
<Text className={styles.label}>Attach documents</Text>
|
||||
<Stack spacing={0}>
|
||||
<ImportDropzoneField
|
||||
uploadIcon={null}
|
||||
value={null}
|
||||
title={''}
|
||||
classNames={{ root: styles.dropzoneRoot }}
|
||||
onChange={handleChangeDropzone}
|
||||
dropzoneProps={{
|
||||
accept: [
|
||||
MIME_TYPES.doc,
|
||||
MIME_TYPES.docx,
|
||||
MIME_TYPES.pdf,
|
||||
MIME_TYPES.png,
|
||||
MIME_TYPES.jpeg,
|
||||
],
|
||||
}}
|
||||
{...dropzoneFieldProps}
|
||||
/>
|
||||
<Group className={styles.hintText}>
|
||||
<Box>Formats: CSV, XLSX</Box>
|
||||
<Box>Maximum: 25MB</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{!isEmpty(localFiles) && (
|
||||
<Stack spacing={8} className={styles.attachments}>
|
||||
{localFiles.map((localFile: AttachmentFile, index: number) => (
|
||||
<Group
|
||||
position={'space-between'}
|
||||
className={styles.attachmentItem}
|
||||
key={index}
|
||||
>
|
||||
<Group spacing={16} className={styles.attachmentContent}>
|
||||
{localFile._loading ? (
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
<Icon
|
||||
icon={'media'}
|
||||
iconSize={16}
|
||||
className={styles.attachmentIcon}
|
||||
/>
|
||||
)}
|
||||
<Stack spacing={2}>
|
||||
<Text className={styles.attachmentFilenameText}>
|
||||
{localFile.originName}
|
||||
</Text>
|
||||
{localFile._loading ? (
|
||||
<Text>Loading...</Text>
|
||||
) : (
|
||||
<Text className={styles.attachmentSizeText}>
|
||||
{formatBytes(localFile.size)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{!localFile._loading && (
|
||||
<Group spacing={0}>
|
||||
<Button small minimal>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
small
|
||||
minimal
|
||||
intent={Intent.DANGER}
|
||||
onClick={handleClick(localFile.key)}
|
||||
>
|
||||
<Icon icon={'trash-16'} iconSize={16} />
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
packages/webapp/src/containers/Attachments/utils.ts
Normal file
19
packages/webapp/src/containers/Attachments/utils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// @ts-nocheck
|
||||
import { transformToForm } from '@/utils';
|
||||
|
||||
const attachmentReqSchema = {
|
||||
key: '',
|
||||
size: '',
|
||||
origin_name: '',
|
||||
mime_type: '',
|
||||
};
|
||||
|
||||
export const transformAttachmentsToForm = (values) => {
|
||||
return values.attachments?.map((attachment) =>
|
||||
transformToForm(attachment, attachmentReqSchema),
|
||||
);
|
||||
};
|
||||
|
||||
export const transformAttachmentsToRequest = (values) => {
|
||||
return values.attachments?.map((attachment) => ({ key: attachment.key }));
|
||||
};
|
||||
Reference in New Issue
Block a user