File previewer (#10260)

Add a file previewer for pdf, image, doc, xls

<img width="991" alt="Screenshot 2025-02-17 at 15 03 10"
src="https://github.com/user-attachments/assets/7516c13d-d6cb-4a10-b10f-b422268d223b"
/>
This commit is contained in:
Félix Malfait
2025-02-18 10:18:59 +01:00
committed by GitHub
parent 270744eca6
commit 103dff4bd0
13 changed files with 573 additions and 21 deletions

View File

@ -32,6 +32,7 @@
"dependencies": {
"@blocknote/xl-docx-exporter": "^0.22.0",
"@blocknote/xl-pdf-exporter": "^0.22.0",
"@cyntler/react-doc-viewer": "^1.17.0",
"@lingui/detect-locale": "^5.2.0",
"@nivo/calendar": "^0.87.0",
"@nivo/core": "^0.87.0",

View File

@ -249,6 +249,7 @@ export type ClientConfig = {
debugMode: Scalars['Boolean'];
defaultSubdomain?: Maybe<Scalars['String']>;
frontDomain: Scalars['String'];
isAttachmentPreviewEnabled: Scalars['Boolean'];
isEmailVerificationRequired: Scalars['Boolean'];
isGoogleCalendarEnabled: Scalars['Boolean'];
isGoogleMessagingEnabled: Scalars['Boolean'];
@ -2221,7 +2222,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, isMicrosoftMessagingEnabled: boolean, isMicrosoftCalendarEnabled: boolean, isGoogleMessagingEnabled: boolean, isGoogleCalendarEnabled: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } };
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, isAttachmentPreviewEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, isMicrosoftMessagingEnabled: boolean, isMicrosoftCalendarEnabled: boolean, isGoogleMessagingEnabled: boolean, isGoogleCalendarEnabled: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } };
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
@ -3730,6 +3731,7 @@ export const GetClientConfigDocument = gql`
frontDomain
debugMode
analyticsEnabled
isAttachmentPreviewEnabled
support {
supportDriver
supportFrontChatId

View File

@ -1,14 +1,25 @@
import styled from '@emotion/styled';
import { ReactElement, useState } from 'react';
import { lazy, ReactElement, Suspense, useState } from 'react';
import { IconButton, IconDownload, IconX } from 'twenty-ui';
import { DropZone } from '@/activities/files/components/DropZone';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { Attachment } from '@/activities/files/types/Attachment';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useRecoilValue } from 'recoil';
import { ActivityList } from '@/activities/components/ActivityList';
import { AttachmentRow } from './AttachmentRow';
const DocumentViewer = lazy(() =>
import('@/activities/files/components/DocumentViewer').then((module) => ({
default: module.DocumentViewer,
})),
);
type AttachmentListProps = {
targetableObject: ActivityTargetableObject;
title: string;
@ -23,9 +34,7 @@ const StyledContainer = styled.div`
flex-direction: column;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2, 6, 6)};
width: calc(100% - ${({ theme }) => theme.spacing(12)});
height: 100%;
`;
@ -51,10 +60,50 @@ const StyledCount = styled.span`
const StyledDropZoneContainer = styled.div`
height: 100%;
width: 100%;
overflow: auto;
`;
const StyledLoadingContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.primary};
display: flex;
height: 80vh;
justify-content: center;
width: 100%;
`;
const StyledLoadingText = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
width: 100%;
`;
const StyledModalTitle = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledModalHeader = styled(Modal.Header)`
padding: 0;
`;
const StyledModalContent = styled(Modal.Content)`
padding-left: 0;
padding-right: 0;
`;
const StyledButtonContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const AttachmentList = ({
targetableObject,
title,
@ -63,11 +112,30 @@ export const AttachmentList = ({
}: AttachmentListProps) => {
const { uploadAttachmentFile } = useUploadAttachmentFile();
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [previewedAttachment, setPreviewedAttachment] =
useState<Attachment | null>(null);
const isAttachmentPreviewEnabled = useRecoilValue(
isAttachmentPreviewEnabledState,
);
const onUploadFile = async (file: File) => {
await uploadAttachmentFile(file, targetableObject);
};
const handlePreview = (attachment: Attachment) => {
if (!isAttachmentPreviewEnabled) return;
setPreviewedAttachment(attachment);
};
const handleClosePreview = () => {
setPreviewedAttachment(null);
};
const handleDownload = () => {
if (!previewedAttachment) return;
downloadFile(previewedAttachment.fullPath, previewedAttachment.name);
};
return (
<>
{attachments && attachments.length > 0 && (
@ -87,13 +155,56 @@ export const AttachmentList = ({
) : (
<ActivityList>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
<AttachmentRow
key={attachment.id}
attachment={attachment}
onPreview={
isAttachmentPreviewEnabled ? handlePreview : undefined
}
/>
))}
</ActivityList>
)}
</StyledDropZoneContainer>
</StyledContainer>
)}
{previewedAttachment && isAttachmentPreviewEnabled && (
<Modal size="large" isClosable onClose={handleClosePreview}>
<StyledModalHeader>
<StyledHeader>
<StyledModalTitle>{previewedAttachment.name}</StyledModalTitle>
<StyledButtonContainer>
<IconButton
Icon={IconDownload}
onClick={handleDownload}
size="small"
/>
<IconButton
Icon={IconX}
onClick={handleClosePreview}
size="small"
/>
</StyledButtonContainer>
</StyledHeader>
</StyledModalHeader>
<StyledModalContent>
<Suspense
fallback={
<StyledLoadingContainer>
<StyledLoadingText>
Loading document viewer...
</StyledLoadingText>
</StyledLoadingContainer>
}
>
<DocumentViewer
documentName={previewedAttachment.name}
documentUrl={previewedAttachment.fullPath}
/>
</Suspense>
</StyledModalContent>
</Modal>
)}
</>
);
};

View File

@ -1,6 +1,7 @@
import { ActivityRow } from '@/activities/components/ActivityRow';
import { AttachmentDropdown } from '@/activities/files/components/AttachmentDropdown';
import { AttachmentIcon } from '@/activities/files/components/AttachmentIcon';
import { PREVIEWABLE_EXTENSIONS } from '@/activities/files/components/DocumentViewer';
import { Attachment } from '@/activities/files/types/Attachment';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -14,6 +15,7 @@ import { TextInput } from '@/ui/input/components/TextInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useMemo, useState } from 'react';
import { isDefined } from 'twenty-shared';
import { IconCalendar, OverflowingTextWithTooltip } from 'twenty-ui';
import { formatToHumanReadableDate } from '~/utils/date-utils';
@ -43,10 +45,17 @@ const StyledCalendarIconContainer = styled.div`
const StyledLink = styled.a`
align-items: center;
appearance: none;
background: none;
border: none;
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
display: flex;
font-family: inherit;
font-size: inherit;
padding: 0;
text-align: left;
text-decoration: none;
width: 100%;
:hover {
@ -59,13 +68,25 @@ const StyledLinkContainer = styled.div`
width: 100%;
`;
export const AttachmentRow = ({ attachment }: { attachment: Attachment }) => {
type AttachmentRowProps = {
attachment: Attachment;
onPreview?: (attachment: Attachment) => void;
};
export const AttachmentRow = ({
attachment,
onPreview,
}: AttachmentRowProps) => {
const theme = useTheme();
const [isEditing, setIsEditing] = useState(false);
const { name: originalFileName, extension: attachmentFileExtension } =
getFileNameAndExtension(attachment.name);
const fileExtension =
attachmentFileExtension?.toLowerCase().replace('.', '') ?? '';
const isPreviewable = PREVIEWABLE_EXTENSIONS.includes(fileExtension);
const [attachmentFileName, setAttachmentFileName] =
useState(originalFileName);
@ -122,6 +143,19 @@ export const AttachmentRow = ({ attachment }: { attachment: Attachment }) => {
);
};
const handleOpenDocument = (e: React.MouseEvent) => {
// Cmd/Ctrl+click opens new tab, right click opens context menu
if (e.metaKey || e.ctrlKey || e.button === 2) {
return;
}
// Only prevent default and use preview if onPreview is provided
if (isDefined(onPreview)) {
e.preventDefault();
onPreview(attachment);
}
};
return (
<FieldContext.Provider value={fieldContext as GenericFieldContextType}>
<ActivityRow disabled>
@ -137,13 +171,22 @@ export const AttachmentRow = ({ attachment }: { attachment: Attachment }) => {
/>
) : (
<StyledLinkContainer>
<StyledLink
href={attachment.fullPath}
target="_blank"
rel="noopener noreferrer"
>
<OverflowingTextWithTooltip text={attachment.name} />
</StyledLink>
{isPreviewable ? (
<StyledLink
onClick={handleOpenDocument}
href={attachment.fullPath}
>
<OverflowingTextWithTooltip text={attachment.name} />
</StyledLink>
) : (
<StyledLink
href={attachment.fullPath}
target="_blank"
rel="noopener noreferrer"
>
<OverflowingTextWithTooltip text={attachment.name} />
</StyledLink>
)}
</StyledLinkContainer>
)}
</StyledLeftContent>

View File

@ -0,0 +1,133 @@
import DocViewer, { DocViewerRenderers } from '@cyntler/react-doc-viewer';
import '@cyntler/react-doc-viewer/dist/index.css';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { getFileNameAndExtension } from '~/utils/file/getFileNameAndExtension';
const StyledDocumentViewerContainer = styled.div`
display: flex;
flex-direction: column;
height: calc(100vh - 200px);
min-height: 500px;
width: 100%;
background: ${({ theme }) => theme.background.secondary};
.react-doc-viewer {
height: 100%;
width: 100%;
overflow: auto;
background: none;
}
#react-doc-viewer #header-bar {
display: none;
}
#react-doc-viewer #pdf-controls {
display: none !important;
}
`;
type DocumentViewerProps = {
documentName: string;
documentUrl: string;
};
export const PREVIEWABLE_EXTENSIONS = [
'bmp',
'csv',
'odt',
'doc',
'docx',
'gif',
'htm',
'html',
'jpg',
'jpeg',
'pdf',
'png',
'ppt',
'pptx',
'tiff',
'txt',
'xls',
'xlsx',
'mp4',
'webp',
];
const MIME_TYPE_MAPPING: Record<
(typeof PREVIEWABLE_EXTENSIONS)[number],
string
> = {
bmp: 'image/bmp',
csv: 'text/csv',
odt: 'application/vnd.oasis.opendocument.text',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
gif: 'image/gif',
htm: 'text/html',
html: 'text/html',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
pdf: 'application/pdf',
png: 'image/png',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
tiff: 'image/tiff',
txt: 'text/plain',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
mp4: 'video/mp4',
webp: 'image/webp',
};
export const DocumentViewer = ({
documentName,
documentUrl,
}: DocumentViewerProps) => {
const theme = useTheme();
const { extension } = getFileNameAndExtension(documentName);
const fileExtension = extension?.toLowerCase().replace('.', '') ?? '';
const mimeType = PREVIEWABLE_EXTENSIONS.includes(fileExtension)
? MIME_TYPE_MAPPING[fileExtension]
: undefined;
return (
<StyledDocumentViewerContainer>
<DocViewer
documents={[
{
uri: documentUrl,
fileName: documentName,
fileType: mimeType,
},
]}
pluginRenderers={DocViewerRenderers}
style={{ height: '100%' }}
config={{
header: {
disableHeader: true,
disableFileName: true,
retainURLParams: false,
},
pdfVerticalScrollByDefault: true,
pdfZoom: {
defaultZoom: 1,
zoomJump: 0.1,
},
}}
theme={{
primary: theme.background.primary,
secondary: theme.background.secondary,
tertiary: theme.background.tertiary,
textPrimary: theme.font.color.primary,
textSecondary: theme.font.color.secondary,
textTertiary: theme.font.color.tertiary,
disableThemeScrollbar: true,
}}
/>
</StyledDocumentViewerContainer>
);
};

View File

@ -6,6 +6,7 @@ import { captchaState } from '@/client-config/states/captchaState';
import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
@ -77,6 +78,10 @@ export const ClientConfigProviderEffect = () => {
isGoogleCalendarEnabledState,
);
const setIsAttachmentPreviewEnabled = useSetRecoilState(
isAttachmentPreviewEnabledState,
);
const { data, loading, error } = useGetClientConfigQuery({
skip: clientConfigApiStatus.isLoaded,
});
@ -149,6 +154,9 @@ export const ClientConfigProviderEffect = () => {
setMicrosoftCalendarEnabled(data?.clientConfig?.isMicrosoftCalendarEnabled);
setGoogleMessagingEnabled(data?.clientConfig?.isGoogleMessagingEnabled);
setGoogleCalendarEnabled(data?.clientConfig?.isGoogleCalendarEnabled);
setIsAttachmentPreviewEnabled(
data?.clientConfig?.isAttachmentPreviewEnabled,
);
}, [
data,
setIsDebugMode,
@ -173,6 +181,7 @@ export const ClientConfigProviderEffect = () => {
setMicrosoftCalendarEnabled,
setGoogleMessagingEnabled,
setGoogleCalendarEnabled,
setIsAttachmentPreviewEnabled,
]);
return <></>;

View File

@ -30,6 +30,7 @@ export const GET_CLIENT_CONFIG = gql`
frontDomain
debugMode
analyticsEnabled
isAttachmentPreviewEnabled
support {
supportDriver
supportFrontChatId

View File

@ -0,0 +1,6 @@
import { createState } from '@ui/utilities/state/utils/createState';
export const isAttachmentPreviewEnabledState = createState<boolean>({
key: 'isAttachmentPreviewEnabled',
defaultValue: false,
});

View File

@ -57,4 +57,5 @@ export const mockedClientConfig: ClientConfig = {
isMicrosoftCalendarEnabled: true,
isGoogleMessagingEnabled: true,
isGoogleCalendarEnabled: true,
isAttachmentPreviewEnabled: true,
};

View File

@ -110,6 +110,9 @@ export class ClientConfig {
@Field(() => Support)
support: Support;
@Field(() => Boolean)
isAttachmentPreviewEnabled: boolean;
@Field(() => Sentry)
sentry: Sentry;

View File

@ -75,6 +75,9 @@ export class ClientConfigResolver {
'MUTATION_MAXIMUM_AFFECTED_RECORDS',
),
},
isAttachmentPreviewEnabled: this.environmentService.get(
'IS_ATTACHMENT_PREVIEW_ENABLED',
),
analyticsEnabled: this.environmentService.get('ANALYTICS_ENABLED'),
canManageFeatureFlags:
this.environmentService.get('NODE_ENV') ===

View File

@ -970,6 +970,15 @@ export class EnvironmentVariables {
@CastToPositiveNumber()
@IsOptional()
HEALTH_MONITORING_TIME_WINDOW_IN_MINUTES = 5;
@EnvironmentVariablesMetadata({
group: EnvironmentVariablesGroup.Other,
description: 'Enable or disable the attachment preview feature',
})
@CastToBoolean()
@IsOptional()
@IsBoolean()
IS_ATTACHMENT_PREVIEW_ENABLED = true;
}
export const validate = (