fix: improve native print output for dashboards and record tables (#22272)
## Summary - Adds print-specific snapshots for dashboards so native browser print/PDF captures charts and front components instead of the app shell. - Adds record-table print snapshots for object index pages so printable tables are generated from visible rows and native print avoids virtualized blank pages. - Preserves rendered chart layers by rasterizing canvas/SVG content for print. ## AI-generated disclosure This pull request was AI-generated by Hermes Agent on behalf of Vittorio Alfieri. The changes were reviewed and tested locally before submission. ## Screenshots ### Dashboard print **Before**  **After**  ### Table records print **Before**  **After**  ## Test plan - [x] `yarn nx typecheck twenty-front` - [x] `yarn nx build twenty-front` - [x] Generated dashboard PDFs from the preview build and rasterized pages to PNG for visual verification. - [x] Generated Tasks/table-record PDFs from the preview build and verified the final page contains table content instead of blank pages. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22272?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+19
-6
@@ -11,6 +11,17 @@ import { RecordTableRecordLimitReloadEffect } from '@/object-record/record-table
|
||||
import { PageFocusId } from '@/types/PageFocusId';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
const StyledRecordTablePrintBoundary = styled.div`
|
||||
display: contents;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
type RecordTableWithWrappersProps = {
|
||||
objectNameSingular: string;
|
||||
@@ -60,12 +71,14 @@ export const RecordTableWithWrappers = ({
|
||||
onRecordIdentifierClick={handleRecordIdentifierClick}
|
||||
>
|
||||
<EntityDeleteContext.Provider value={deleteOneRecord}>
|
||||
<ScrollWrapper
|
||||
componentInstanceId={`record-table-scroll-${recordTableId}`}
|
||||
>
|
||||
<RecordTableRecordLimitReloadEffect />
|
||||
<RecordTable />
|
||||
</ScrollWrapper>
|
||||
<StyledRecordTablePrintBoundary>
|
||||
<ScrollWrapper
|
||||
componentInstanceId={`record-table-scroll-${recordTableId}`}
|
||||
>
|
||||
<RecordTableRecordLimitReloadEffect />
|
||||
<RecordTable />
|
||||
</ScrollWrapper>
|
||||
</StyledRecordTablePrintBoundary>
|
||||
</EntityDeleteContext.Provider>
|
||||
</RecordTableContextProvider>
|
||||
</RecordTableComponentInstance>
|
||||
|
||||
@@ -86,6 +86,34 @@ const StyledGridContainer = styled.div`
|
||||
.react-grid-item:hover .widget-card-resize-handle {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
@media print {
|
||||
min-height: auto;
|
||||
padding: 0;
|
||||
user-select: auto;
|
||||
|
||||
.react-grid-layout {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
// Flow the absolutely-positioned grid items into the page, but keep the
|
||||
// pixel width and height react-grid-layout sets inline: the charts are sized
|
||||
// by a resize observer (Nivo SVG and a custom canvas bar chart), so changing
|
||||
// their box would re-measure mid-print and render them blank.
|
||||
.react-grid-item {
|
||||
break-inside: avoid;
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
page-break-inside: avoid;
|
||||
position: static !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.react-grid-placeholder,
|
||||
.react-resizable-handle,
|
||||
.widget-card-resize-handle {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type ExtendedResponsiveProps = ResponsiveProps & {
|
||||
|
||||
@@ -36,17 +36,41 @@ const StyledContainer = styled.div<{ hasPinnedTab: boolean }>`
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTabsAndDashboardContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
|
||||
.page-layout-tab-list-print-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledScrollWrapperContainer = styled.div`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@media print {
|
||||
min-height: auto;
|
||||
|
||||
.page-layout-scroll-wrapper {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const PageLayoutTabsRenderer = () => {
|
||||
@@ -184,6 +208,7 @@ export const PageLayoutTabsRenderer = () => {
|
||||
/>
|
||||
{(sortedActiveTabs.length > 1 || isPageLayoutInEditMode) && (
|
||||
<PageLayoutTabList
|
||||
className="page-layout-tab-list-print-hidden"
|
||||
tabs={sortedActiveTabs}
|
||||
behaveAsLinks={!isInSidePanel && !isPageLayoutInEditMode}
|
||||
isInSidePanel={isInSidePanel}
|
||||
@@ -206,6 +231,7 @@ export const PageLayoutTabsRenderer = () => {
|
||||
|
||||
<StyledScrollWrapperContainer>
|
||||
<ScrollWrapper
|
||||
className="page-layout-scroll-wrapper"
|
||||
componentInstanceId={getScrollWrapperInstanceIdFromPageLayoutId(
|
||||
currentPageLayout.id,
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,13 @@ const StyledLayout = styled.div`
|
||||
*::-webkit-scrollbar-thumb {
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
}
|
||||
|
||||
@media print {
|
||||
background: ${themeCssVariables.background.primary};
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPageContainer = styled.div`
|
||||
@@ -46,10 +53,21 @@ const StyledPageContainer = styled.div`
|
||||
flex-direction: row;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
min-width: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledNavigationDrawerWrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledMainContainer = styled.div`
|
||||
@@ -57,6 +75,12 @@ const StyledMainContainer = styled.div`
|
||||
flex: 0 1 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
min-width: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DefaultLayout = () => {
|
||||
|
||||
+32
@@ -23,6 +23,18 @@ const StyledRow = styled.div`
|
||||
flex-direction: row;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
min-width: auto;
|
||||
|
||||
// Only the main content (first child) is printed; the side panel and its
|
||||
// resize chrome that follow it are hidden.
|
||||
> *:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled.div`
|
||||
@@ -31,6 +43,13 @@ const StyledContent = styled.div`
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
min-width: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContentTransitionContainer = styled.div`
|
||||
@@ -39,6 +58,13 @@ const StyledContentTransitionContainer = styled.div`
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
min-width: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContentTransitionPage = styled(motion.div)`
|
||||
@@ -47,6 +73,12 @@ const StyledContentTransitionPage = styled(motion.div)`
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
min-width: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const MainAppLayoutOutlet = () => {
|
||||
|
||||
@@ -16,6 +16,12 @@ const StyledRoot = styled.div`
|
||||
flex-direction: row;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
min-width: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledMainCardWrapper = styled.div`
|
||||
@@ -26,6 +32,14 @@ const StyledMainCardWrapper = styled.div`
|
||||
min-width: 0;
|
||||
padding-left: 4px;
|
||||
width: 0;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
min-width: auto;
|
||||
padding-left: 0;
|
||||
width: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
// oxlint-disable-next-line twenty/no-hardcoded-colors
|
||||
@@ -48,6 +62,14 @@ const StyledCard = styled.div`
|
||||
-4px 0 4px 0 rgba(0, 0, 0, 0.03),
|
||||
0 0 0 1px ${themeCssVariables.border.color.medium};
|
||||
}
|
||||
|
||||
@media print {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
display: block;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledBodyContent = styled.div`
|
||||
@@ -56,6 +78,17 @@ const StyledBodyContent = styled.div`
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPrintHidden = styled.div`
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const PageCardLayout = ({
|
||||
@@ -68,10 +101,14 @@ export const PageCardLayout = ({
|
||||
<StyledRoot>
|
||||
<StyledMainCardWrapper>
|
||||
<StyledCard>
|
||||
{header}
|
||||
{secondaryBar}
|
||||
<StyledPrintHidden>{header}</StyledPrintHidden>
|
||||
<StyledPrintHidden>{secondaryBar}</StyledPrintHidden>
|
||||
<StyledBodyContent>
|
||||
{showInformationBanner && <InformationBannerWrapper />}
|
||||
{showInformationBanner && (
|
||||
<StyledPrintHidden>
|
||||
<InformationBannerWrapper />
|
||||
</StyledPrintHidden>
|
||||
)}
|
||||
{children}
|
||||
</StyledBodyContent>
|
||||
</StyledCard>
|
||||
|
||||
@@ -17,6 +17,12 @@ const StyledPageLayoutContainer = styled.div`
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StandalonePageLayoutPage = () => {
|
||||
|
||||
Reference in New Issue
Block a user