diff --git a/static/app/components/actions/actionLink.tsx b/static/app/components/actions/actionLink.tsx index 56b46965c02ed4..d96fc65e49348e 100644 --- a/static/app/components/actions/actionLink.tsx +++ b/static/app/components/actions/actionLink.tsx @@ -49,6 +49,7 @@ export default function ActionLink({ children, shouldConfirm, confirmPriority, + header, ...props }: Props) { const actionCommonProps = { @@ -74,6 +75,7 @@ export default function ActionLink({ priority={confirmPriority} disabled={disabled} message={message} + header={header} confirmText={confirmLabel} onConfirm={onAction} stopPropagation={disabled} diff --git a/static/app/components/actions/confirmableAction.tsx b/static/app/components/actions/confirmableAction.tsx index f0ca1e5efdd7dc..65e7279affcc85 100644 --- a/static/app/components/actions/confirmableAction.tsx +++ b/static/app/components/actions/confirmableAction.tsx @@ -6,7 +6,9 @@ type ConfirmProps = React.ComponentProps; type Props = { children: React.ReactNode | ConfirmProps['children']; shouldConfirm?: boolean; -} & Partial> & +} & Partial< + Pick +> & Pick; export default function ConfirmableAction({shouldConfirm, children, ...props}: Props) { diff --git a/static/app/components/events/contexts/utils.tsx b/static/app/components/events/contexts/utils.tsx index e7be6d0b49fdc1..718ed2e2d01f51 100644 --- a/static/app/components/events/contexts/utils.tsx +++ b/static/app/components/events/contexts/utils.tsx @@ -40,7 +40,8 @@ export function getSourcePlugin(pluginContexts: Array, contextType: string) export function getRelativeTimeFromEventDateCreated( eventDateCreated: string, - timestamp?: string + timestamp?: string, + showTimestamp = true ) { if (!defined(timestamp)) { return timestamp; @@ -56,6 +57,10 @@ export function getRelativeTimeFromEventDateCreated( 'before this event' )})`; + if (!showTimestamp) { + return {relativeTime}; + } + return ( {timestamp} diff --git a/static/app/components/events/eventTagsAndScreenshot/dataSection.tsx b/static/app/components/events/eventTagsAndScreenshot/dataSection.tsx index 713a63b46325b0..5ec8408292e76d 100644 --- a/static/app/components/events/eventTagsAndScreenshot/dataSection.tsx +++ b/static/app/components/events/eventTagsAndScreenshot/dataSection.tsx @@ -51,7 +51,6 @@ const Title = styled('h3')` const StyledEventDataSection = styled(EventDataSection)` ${SectionContents} { flex: 1; - overflow: hidden; } @media (min-width: ${p => p.theme.breakpoints[0]}) { diff --git a/static/app/components/events/eventTagsAndScreenshot/screenshot/emptyState.tsx b/static/app/components/events/eventTagsAndScreenshot/screenshot/emptyState.tsx deleted file mode 100644 index 855ab852ab103f..00000000000000 --- a/static/app/components/events/eventTagsAndScreenshot/screenshot/emptyState.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import styled from '@emotion/styled'; - -import emptyStateImg from 'sentry-images/spot/feedback-empty-state.svg'; - -import Button, {ButtonLabel} from 'app/components/button'; -import ButtonBar from 'app/components/buttonBar'; -import {t} from 'app/locale'; -import space from 'app/styles/space'; -import {PlatformType} from 'app/types'; - -import {getConfigureAttachmentsDocsLink} from './utils'; - -type Props = { - platform?: PlatformType; -}; - -function EmptyState({platform}: Props) { - const configureAttachmentsDocsLink = getConfigureAttachmentsDocsLink(platform); - - return ( - - - - - {'|'} - - - - ); -} - -export default EmptyState; - -const Wrapper = styled('div')` - width: 100%; - overflow: hidden; - padding: ${space(2)} ${space(2)} ${space(1)} ${space(2)}; - display: flex; - flex-direction: column; - &, - img { - flex: 1; - } - img { - height: 100%; - width: auto; - overflow: hidden; - max-height: 100%; - } -`; - -const StyledButtonbar = styled(ButtonBar)` - color: ${p => p.theme.gray200}; - justify-content: flex-start; - margin-top: ${space(2)}; - ${ButtonLabel} { - font-size: ${p => p.theme.fontSizeMedium}; - white-space: nowrap; - } -`; diff --git a/static/app/components/events/eventTagsAndScreenshot/screenshot/imageVisualization.tsx b/static/app/components/events/eventTagsAndScreenshot/screenshot/imageVisualization.tsx new file mode 100644 index 00000000000000..11a1f2ba01a6cf --- /dev/null +++ b/static/app/components/events/eventTagsAndScreenshot/screenshot/imageVisualization.tsx @@ -0,0 +1,16 @@ +import styled from '@emotion/styled'; + +import ImageViewer from 'app/components/events/attachmentViewers/imageViewer'; + +const ImageVisualization = styled(ImageViewer)` + padding: 0; + height: 100%; + img { + width: auto; + height: 100%; + object-fit: cover; + flex: 1; + } +`; + +export default ImageVisualization; diff --git a/static/app/components/events/eventTagsAndScreenshot/screenshot/index.tsx b/static/app/components/events/eventTagsAndScreenshot/screenshot/index.tsx index 6fc5684af6de6d..deba33d1d4a609 100644 --- a/static/app/components/events/eventTagsAndScreenshot/screenshot/index.tsx +++ b/static/app/components/events/eventTagsAndScreenshot/screenshot/index.tsx @@ -1,16 +1,15 @@ import {Fragment, useEffect, useState} from 'react'; import styled from '@emotion/styled'; +import {openModal} from 'app/actionCreators/modal'; import {Client} from 'app/api'; import Role from 'app/components/acl/role'; import MenuItemActionLink from 'app/components/actions/menuItemActionLink'; import Button from 'app/components/button'; import ButtonBar from 'app/components/buttonBar'; import DropdownLink from 'app/components/dropdownLink'; -import ImageViewer from 'app/components/events/attachmentViewers/imageViewer'; -import LoadingIndicator from 'app/components/loadingIndicator'; import {Panel, PanelBody, PanelFooter} from 'app/components/panels'; -import {IconDownload, IconEllipsis} from 'app/icons'; +import {IconEllipsis} from 'app/icons'; import {t} from 'app/locale'; import space from 'app/styles/space'; import {EventAttachment, Organization, Project} from 'app/types'; @@ -19,8 +18,8 @@ import withApi from 'app/utils/withApi'; import DataSection from '../dataSection'; -import EmptyState from './emptyState'; -import {platformsMobileWithAttachmentsFeature} from './utils'; +import ImageVisualization from './imageVisualization'; +import Modal, {modalCss} from './modal'; type Props = { event: Event; @@ -33,7 +32,6 @@ function Screenshot({event, api, organization, projectSlug}: Props) { const [attachments, setAttachments] = useState([]); const [isLoading, setIsLoading] = useState(false); const orgSlug = organization.slug; - const eventPlatform = event.platform; useEffect(() => { fetchData(); @@ -59,35 +57,53 @@ function Screenshot({event, api, organization, projectSlug}: Props) { } } - function hasPreview(attachment: EventAttachment) { - switch (attachment.mimetype) { - case 'image/jpeg': - case 'image/png': - case 'image/gif': - return true; - default: - return false; - } + function hasScreenshot(attachment: EventAttachment) { + const {mimetype} = attachment; + return mimetype === 'image/jpeg' || mimetype === 'image/png'; } - function renderContent() { - if (isLoading) { - return ; - } - - const firstAttachmenteWithPreview = attachments.find(hasPreview); + async function handleDelete(screenshotAttachmentId: string, downloadUrl: string) { + try { + await api.requestPromise(downloadUrl.split('/api/0')[1], { + method: 'DELETE', + }); - if (!firstAttachmenteWithPreview) { - return ; + setAttachments( + attachments.filter(attachment => attachment.id !== screenshotAttachmentId) + ); + } catch (_err) { + // TODO: Error-handling } + } - const downloadUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/attachments/${firstAttachmenteWithPreview.id}/`; + function handleOpenVisualizationModal( + eventAttachment: EventAttachment, + downloadUrl: string + ) { + openModal( + modalProps => ( + handleDelete(eventAttachment.id, downloadUrl)} + /> + ), + {modalCss} + ); + } + + function renderContent(screenshotAttachment: EventAttachment) { + const downloadUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/attachments/${screenshotAttachment.id}/`; return ( - - + } title={t('Download')} href={`${downloadUrl}?download=1`} > {t('Download')} + handleDelete(screenshotAttachment.id, downloadUrl)} + header={t( + 'Screenshots help identify what the user saw when the event happened' + )} + message={t('Are you sure you wish to delete this screenshot?')} + > + {t('Delete')} + @@ -122,30 +158,23 @@ function Screenshot({event, api, organization, projectSlug}: Props) { ); } - // the UI should only render the screenshots feature in events with platforms that support screenshots - if ( - !eventPlatform || - !platformsMobileWithAttachmentsFeature.includes(eventPlatform as any) - ) { - return null; - } - return ( {({hasRole}) => { - if (!hasRole) { - // if the user has no access to the attachments, - // the UI shall not display the screenshot section + const screenshotAttachment = attachments.find(hasScreenshot); + + if (!hasRole || isLoading || !screenshotAttachment) { return null; } + return ( - {renderContent()} + {renderContent(screenshotAttachment)} ); }} @@ -161,14 +190,19 @@ const StyledPanel = styled(Panel)` justify-content: center; align-items: center; margin-bottom: 0; - min-width: 175px; min-height: 200px; + min-width: 175px; `; const StyledPanelBody = styled(PanelBody)` height: 175px; - width: 100%; overflow: hidden; + border: 1px solid ${p => p.theme.border}; + border-radius: ${p => p.theme.borderRadius}; + margin: -1px; + width: calc(100% + 2px); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; `; const StyledPanelFooter = styled(PanelFooter)` @@ -182,14 +216,3 @@ const StyledButtonbar = styled(ButtonBar)` height: 24px; } `; - -const StyledImageViewer = styled(ImageViewer)` - padding: 0; - height: 100%; - img { - width: auto; - height: 100%; - object-fit: cover; - flex: 1; - } -`; diff --git a/static/app/components/events/eventTagsAndScreenshot/screenshot/modal.tsx b/static/app/components/events/eventTagsAndScreenshot/screenshot/modal.tsx new file mode 100644 index 00000000000000..ad101855a3b34e --- /dev/null +++ b/static/app/components/events/eventTagsAndScreenshot/screenshot/modal.tsx @@ -0,0 +1,161 @@ +import {Fragment} from 'react'; +import {css} from '@emotion/react'; +import styled from '@emotion/styled'; + +import {ModalRenderProps} from 'app/actionCreators/modal'; +import Button from 'app/components/button'; +import Buttonbar from 'app/components/buttonBar'; +import Confirm from 'app/components/confirm'; +import DateTime from 'app/components/dateTime'; +import {getRelativeTimeFromEventDateCreated} from 'app/components/events/contexts/utils'; +import NotAvailable from 'app/components/notAvailable'; +import {t} from 'app/locale'; +import space from 'app/styles/space'; +import {EventAttachment, Organization, Project} from 'app/types'; +import {Event} from 'app/types/event'; +import getDynamicText from 'app/utils/getDynamicText'; + +import ImageVisualization from './imageVisualization'; + +type Props = ModalRenderProps & { + eventAttachment: EventAttachment; + orgSlug: Organization['slug']; + projectSlug: Project['slug']; + event: Event; + onDelete: () => void; + downloadUrl: string; +}; + +function Modal({ + eventAttachment, + orgSlug, + projectSlug, + Header, + Body, + Footer, + event, + onDelete, + downloadUrl, +}: Props) { + const {dateCreated, name, size, mimetype, type} = eventAttachment; + return ( + +
+ + {t('Screenshot')} + <FileName> + {name ? name.split(`.${name.split('.').pop()}`)[0] : t('Unknown')} + </FileName> + +
+ + + + + {dateCreated ? ( + + + {getRelativeTimeFromEventDateCreated( + event.dateCreated, + dateCreated, + false + )} + + ) : ( + + )} + + + + {name ?? } + + + {size ?? } + + + {mimetype ?? } + + + {type ?? } + + + + +
+ + + + + + +
+
+ ); +} + +export default Modal; + +const Title = styled('div')` + display: grid; + grid-template-columns: max-content 1fr; + grid-gap: ${space(1)}; + align-items: center; + font-size: ${p => p.theme.fontSizeExtraLarge}; + max-width: calc(100% - 40px); + word-break: break-all; +`; + +const FileName = styled('span')` + font-family: ${p => p.theme.text.familyMono}; +`; + +const GeralInfo = styled('div')` + display: grid; + grid-template-columns: max-content 1fr; + margin-bottom: ${space(3)}; +`; + +const Label = styled('div')<{coloredBg?: boolean}>` + color: ${p => p.theme.textColor}; + padding: ${space(1)} ${space(1.5)} ${space(1)} ${space(1)}; + ${p => p.coloredBg && `background-color: ${p.theme.backgroundSecondary};`} +`; + +const Value = styled(Label)` + white-space: pre-wrap; + word-break: break-all; + color: ${p => p.theme.subText}; + padding: ${space(1)}; + font-family: ${p => p.theme.text.familyMono}; + ${p => p.coloredBg && `background-color: ${p.theme.backgroundSecondary};`} +`; + +const StyledImageVisualization = styled(ImageVisualization)` + img { + border-radius: ${p => p.theme.borderRadius}; + } +`; + +export const modalCss = css` + width: auto; + height: 100%; + max-width: 100%; +`; diff --git a/static/app/components/events/eventTagsAndScreenshot/screenshot/utils.tsx b/static/app/components/events/eventTagsAndScreenshot/screenshot/utils.tsx deleted file mode 100644 index 188b6d6dcf5ab0..00000000000000 --- a/static/app/components/events/eventTagsAndScreenshot/screenshot/utils.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import {PlatformType} from 'app/types'; - -export const platformsMobileWithAttachmentsFeature = ['android', 'apple'] as const; - -const platformsWithAttachmentsFeature = [ - 'dotnet', - 'javascript', - 'native', - ...platformsMobileWithAttachmentsFeature, -] as const; - -type DocPlatform = typeof platformsWithAttachmentsFeature[number]; - -function validDocPlatform(platform: any): platform is DocPlatform { - if (!platform) { - return false; - } - return platformsWithAttachmentsFeature.includes(platform); -} - -export function getConfigureAttachmentsDocsLink(platform?: PlatformType) { - if (!platform || !validDocPlatform(platform)) { - return undefined; - } - - return `https://docs.sentry.io/platforms/${platform}/enriching-events/attachments/`; -} diff --git a/static/app/components/events/eventTagsAndScreenshot/tags.tsx b/static/app/components/events/eventTagsAndScreenshot/tags.tsx index bd42a206e0b614..451b9bd76b9a69 100644 --- a/static/app/components/events/eventTagsAndScreenshot/tags.tsx +++ b/static/app/components/events/eventTagsAndScreenshot/tags.tsx @@ -1,5 +1,7 @@ +import styled from '@emotion/styled'; import {Location} from 'history'; +import {SectionContents} from 'app/components/events/eventDataSection'; import {t} from 'app/locale'; import {Organization, Project} from 'app/types'; import {Event} from 'app/types/event'; @@ -27,7 +29,7 @@ function Tags({ hasQueryFeature, }: Props) { return ( - - + ); } export default Tags; + +const StyledDataSection = styled(DataSection)` + ${SectionContents} { + overflow: hidden; + } +`;