Attachments (#2716)

* create attachment site

* add deletion

* - fix person create attachment

* - add presentation type
- add some more file endings
- various fixes
This commit is contained in:
brendanlaschke
2023-11-29 16:58:58 +01:00
committed by GitHub
parent d50cf5291a
commit 7e454d2013
15 changed files with 654 additions and 14 deletions

View File

@ -0,0 +1,64 @@
import { IconDotsVertical, IconDownload, IconTrash } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
type AttachmentDropdownProps = {
onDownload: () => void;
onDelete: () => void;
scopeKey: string;
};
export const AttachmentDropdown = ({
onDownload,
onDelete,
scopeKey,
}: AttachmentDropdownProps) => {
const dropdownScopeId = `${scopeKey}-settings-field-active-action-dropdown`;
const { closeDropdown } = useDropdown({ dropdownScopeId });
const handleDownload = () => {
onDownload();
closeDropdown();
};
const handleDelete = () => {
onDelete();
closeDropdown();
};
return (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownMenu width="160px">
<DropdownMenuItemsContainer>
<MenuItem
text="Download"
LeftIcon={IconDownload}
onClick={handleDownload}
/>
<MenuItem
text="Delete"
accent="danger"
LeftIcon={IconTrash}
onClick={handleDelete}
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
}
dropdownHotkeyScope={{
scope: dropdownScopeId,
}}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,64 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
Attachment,
AttachmentType,
} from '@/activities/files/types/Attachment';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import {
IconFile,
IconFileText,
IconFileZip,
IconHeadphones,
IconPhoto,
IconPresentation,
IconTable,
IconVideo,
} from '@/ui/input/constants/icons';
const StyledIconContainer = styled.div<{ background: string }>`
align-items: center;
background: ${({ background }) => background};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.grayScale.gray0};
display: flex;
flex-shrink: 0;
height: 20px;
justify-content: center;
width: 20px;
`;
const IconMapping: { [key in AttachmentType]: IconComponent } = {
Archive: IconFileZip,
Audio: IconHeadphones,
Image: IconPhoto,
Presentation: IconPresentation,
Spreadsheet: IconTable,
TextDocument: IconFileText,
Video: IconVideo,
Other: IconFile,
};
export const AttachmentIcon = ({ attachment }: { attachment: Attachment }) => {
const theme = useTheme();
const IconColors: { [key in AttachmentType]: string } = {
Archive: theme.color.gray,
Audio: theme.color.pink,
Image: theme.color.yellow,
Presentation: theme.color.orange,
Spreadsheet: theme.color.turquoise,
TextDocument: theme.color.blue,
Video: theme.color.purple,
Other: theme.color.gray,
};
const Icon = IconMapping[attachment.type];
return (
<StyledIconContainer background={IconColors[attachment.type]}>
{Icon && <Icon size={theme.icon.size.sm} />}
</StyledIconContainer>
);
};

View File

@ -0,0 +1,76 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { Attachment } from '@/activities/files/types/Attachment';
import { AttachmentRow } from './AttachmentRow';
type AttachmentListProps = {
title: string;
attachments: Attachment[];
button?: ReactElement | false;
};
const StyledContainer = styled.div`
align-items: flex-start;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
padding: 8px 24px;
`;
const StyledTitleBar = styled.h3`
display: flex;
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(4)};
place-items: center;
width: 100%;
`;
const StyledTitle = styled.h3`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledCount = styled.span`
color: ${({ theme }) => theme.font.color.light};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledAttachmentContainer = styled.div`
align-items: flex-start;
align-self: stretch;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
disply: flex;
flex-flow: column nowrap;
justify-content: center;
width: 100%;
`;
export const AttachmentList = ({
title,
attachments,
button,
}: AttachmentListProps) => (
<>
{attachments && attachments.length > 0 && (
<StyledContainer>
<StyledTitleBar>
<StyledTitle>
{title} <StyledCount>{attachments.length}</StyledCount>
</StyledTitle>
{button}
</StyledTitleBar>
<StyledAttachmentContainer>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
</StyledAttachmentContainer>
</StyledContainer>
)}
</>
);

View File

@ -0,0 +1,104 @@
import { useMemo } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AttachmentDropdown } from '@/activities/files/components/AttachmentDropdown';
import { AttachmentIcon } from '@/activities/files/components/AttachmentIcon';
import { Attachment } from '@/activities/files/types/Attachment';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { useDeleteOneObjectRecord } from '@/object-record/hooks/useDeleteOneObjectRecord';
import { IconCalendar } from '@/ui/display/icon';
import {
FieldContext,
GenericFieldContextType,
} from '@/ui/object/field/contexts/FieldContext';
import { formatToHumanReadableDate } from '~/utils';
const StyledRow = styled.div`
align-items: center;
align-self: stretch;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledLeftContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
`;
const StyledRightContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledCalendarIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
`;
const StyledLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
text-decoration: none;
:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
export const AttachmentRow = ({ attachment }: { attachment: Attachment }) => {
const theme = useTheme();
const fieldContext = useMemo(
() => ({ recoilScopeId: attachment?.id ?? '' }),
[attachment?.id],
);
const { deleteOneObject: deleteOneAttachment } =
useDeleteOneObjectRecord<Attachment>({
objectNameSingular: 'attachment',
});
const handleDelete = () => {
deleteOneAttachment(attachment.id);
};
return (
<FieldContext.Provider value={fieldContext as GenericFieldContextType}>
<StyledRow>
<StyledLeftContent>
<AttachmentIcon attachment={attachment} />
<StyledLink
href={
process.env.REACT_APP_SERVER_BASE_URL +
'/files/' +
attachment.fullPath
}
target="__blank"
>
{attachment.name}
</StyledLink>
</StyledLeftContent>
<StyledRightContent>
<StyledCalendarIconContainer>
<IconCalendar size={theme.icon.size.md} />
</StyledCalendarIconContainer>
{formatToHumanReadableDate(attachment.createdAt)}
<AttachmentDropdown
scopeKey={attachment.id}
onDelete={handleDelete}
onDownload={() => {
downloadFile(attachment.fullPath, attachment.name);
}}
/>
</StyledRightContent>
</StyledRow>
</FieldContext.Provider>
);
};

View File

@ -0,0 +1,152 @@
import { ChangeEvent, useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { AttachmentList } from '@/activities/files/components/AttachmentList';
import { useAttachments } from '@/activities/files/hooks/useAttachments';
import { Attachment } from '@/activities/files/types/Attachment';
import { getFileType } from '@/activities/files/utils/getFileType';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCreateOneObjectRecord } from '@/object-record/hooks/useCreateOneObjectRecord';
import { IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button';
import { FileFolder, useUploadFileMutation } from '~/generated/graphql';
const StyledTaskGroupEmptyContainer = styled.div`
align-items: center;
align-self: stretch;
display: flex;
flex: 1 0 0;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
padding-bottom: ${({ theme }) => theme.spacing(16)};
padding-left: ${({ theme }) => theme.spacing(4)};
padding-right: ${({ theme }) => theme.spacing(4)};
padding-top: ${({ theme }) => theme.spacing(3)};
`;
const StyledEmptyTaskGroupTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.xxl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: ${({ theme }) => theme.text.lineHeight.md};
`;
const StyledEmptyTaskGroupSubTitle = styled.div`
color: ${({ theme }) => theme.font.color.extraLight};
font-size: ${({ theme }) => theme.font.size.xxl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: ${({ theme }) => theme.text.lineHeight.md};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledAttachmentsContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
overflow: auto;
`;
const StyledFileInput = styled.input`
display: none;
`;
export const Attachments = ({
targetableEntity,
}: {
targetableEntity: ActivityTargetableEntity;
}) => {
const inputFileRef = useRef<HTMLInputElement>(null);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { attachments } = useAttachments(targetableEntity);
const [uploadFile] = useUploadFileMutation();
const { createOneObject: createOneAttachment } =
useCreateOneObjectRecord<Attachment>({
objectNameSingular: 'attachment',
});
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) onUploadFile?.(e.target.files[0]);
};
const handleUploadFileClick = () => {
inputFileRef?.current?.click?.();
};
const onUploadFile = async (file: File) => {
const result = await uploadFile({
variables: {
file,
fileFolder: FileFolder.Attachment,
},
});
const attachmentUrl = result?.data?.uploadFile;
if (!attachmentUrl) {
return;
}
if (!createOneAttachment) {
return;
}
await createOneAttachment({
authorId: currentWorkspaceMember?.id,
name: file.name,
fullPath: attachmentUrl,
type: getFileType(file.name),
companyId:
targetableEntity.type == 'Company' ? targetableEntity.id : null,
personId: targetableEntity.type == 'Person' ? targetableEntity.id : null,
});
};
if (attachments?.length === 0 && targetableEntity.type !== 'Custom') {
return (
<StyledTaskGroupEmptyContainer>
<StyledFileInput
ref={inputFileRef}
onChange={handleFileChange}
type="file"
/>
<StyledEmptyTaskGroupTitle>No files yet</StyledEmptyTaskGroupTitle>
<StyledEmptyTaskGroupSubTitle>Upload one:</StyledEmptyTaskGroupSubTitle>
<Button
Icon={IconPlus}
title="Add file"
variant="secondary"
onClick={handleUploadFileClick}
/>
</StyledTaskGroupEmptyContainer>
);
}
return (
<StyledAttachmentsContainer>
<StyledFileInput
ref={inputFileRef}
onChange={handleFileChange}
type="file"
/>
<AttachmentList
title="All"
attachments={attachments ?? []}
button={
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title="Add file"
onClick={handleUploadFileClick}
></Button>
}
/>
</StyledAttachmentsContainer>
);
};

View File

@ -0,0 +1,20 @@
import { Attachment } from '@/activities/files/types/Attachment';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { ActivityTargetableEntity } from '../../types/ActivityTargetableEntity';
export const useAttachments = (entity: ActivityTargetableEntity) => {
const { objects: attachments } = useFindManyObjectRecords({
objectNamePlural: 'attachments',
filter: {
[entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id },
},
orderBy: {
createdAt: 'DescNullsFirst',
},
});
return {
attachments: attachments as Attachment[],
};
};

View File

@ -0,0 +1,20 @@
export type Attachment = {
id: string;
name: string;
fullPath: string;
type: AttachmentType;
companyId: string;
personId: string;
activityId: string;
authorId: string;
createdAt: string;
};
export type AttachmentType =
| 'Archive'
| 'Audio'
| 'Image'
| 'Presentation'
| 'Spreadsheet'
| 'TextDocument'
| 'Video'
| 'Other';

View File

@ -0,0 +1,18 @@
export const downloadFile = (fullPath: string, fileName: string) => {
fetch(process.env.REACT_APP_SERVER_BASE_URL + '/files/' + fullPath)
.then((resp) =>
resp.status === 200
? resp.blob()
: Promise.reject('Failed downloading file'),
)
.then((blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
});
};

View File

@ -0,0 +1,55 @@
import { AttachmentType } from '@/activities/files/types/Attachment';
const FileExtensionMapping: { [key: string]: AttachmentType } = {
doc: 'TextDocument',
docm: 'TextDocument',
docx: 'TextDocument',
dot: 'TextDocument',
dotx: 'TextDocument',
odt: 'TextDocument',
pdf: 'TextDocument',
txt: 'TextDocument',
rtf: 'TextDocument',
xls: 'Spreadsheet',
xlsb: 'Spreadsheet',
xlsm: 'Spreadsheet',
xlsx: 'Spreadsheet',
xltx: 'Spreadsheet',
csv: 'Spreadsheet',
tsv: 'Spreadsheet',
ods: 'Spreadsheet',
ppt: 'Presentation',
pptx: 'Presentation',
potx: 'Presentation',
odp: 'Presentation',
html: 'Presentation',
png: 'Image',
jpg: 'Image',
jpeg: 'Image',
svg: 'Image',
gif: 'Image',
webp: 'Image',
heif: 'Image',
tif: 'Image',
tiff: 'Image',
bmp: 'Image',
mp4: 'Video',
avi: 'Video',
mov: 'Video',
wmv: 'Video',
mpg: 'Video',
mpeg: 'Video',
mp3: 'Audio',
zip: 'Archive',
tar: 'Archive',
iso: 'Archive',
gz: 'Archive',
};
export const getFileType = (fileName: string): AttachmentType => {
const fileExtension = fileName.split('.').at(-1);
if (!fileExtension) {
return 'Other';
}
return FileExtensionMapping[fileExtension.toLowerCase()] ?? 'Other';
};

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const UPLOAD_FILE = gql`
mutation uploadFile($file: Upload!, $fileFolder: FileFolder) {
uploadFile(file: $file, fileFolder: $fileFolder)
}
`;

View File

@ -36,6 +36,7 @@ export {
IconCurrencyDollar,
IconDatabase,
IconDotsVertical,
IconDownload,
IconEye,
IconEyeOff,
IconFileCheck,
@ -64,6 +65,7 @@ export {
IconMouse2,
IconNotes,
IconNumbers,
IconPaperclip,
IconPencil,
IconPhone,
IconPlug,

View File

@ -1,5 +1,6 @@
import styled from '@emotion/styled';
import { Attachments } from '@/activities/files/components/Attachments';
import { Notes } from '@/activities/notes/components/Notes';
import { EntityTasks } from '@/activities/tasks/components/EntityTasks';
import { Timeline } from '@/activities/timeline/components/Timeline';
@ -8,6 +9,7 @@ import {
IconCheckbox,
IconMail,
IconNotes,
IconPaperclip,
IconTimelineEvent,
} from '@/ui/display/icon';
import { TabList } from '@/ui/layout/tab/components/TabList';
@ -72,6 +74,13 @@ export const ShowPageRightContainer = ({
hide: !notes,
disabled: entity.type === 'Custom',
},
{
id: 'files',
title: 'Files',
Icon: IconPaperclip,
hide: !notes,
disabled: entity.type === 'Custom',
},
{
id: 'emails',
title: 'Emails',
@ -94,6 +103,7 @@ export const ShowPageRightContainer = ({
{activeTabId === 'timeline' && <Timeline entity={entity} />}
{activeTabId === 'tasks' && <EntityTasks entity={entity} />}
{activeTabId === 'notes' && <Notes entity={entity} />}
{activeTabId === 'files' && <Attachments targetableEntity={entity} />}
</StyledShowPageRightContainer>
);
};