Files
twenty/packages/twenty-front/src/modules/ui/layout/page/components/MainAppLayoutWithSidePanel.tsx
T
vittolago fb800e7a2d 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**

![Dashboard print
before](https://raw.githubusercontent.com/vittolago/twenty/fix/dashboard-native-print/packages/twenty-front/docs/print-screenshots/dashboard-before.png)

**After**

![Dashboard print
after](https://raw.githubusercontent.com/vittolago/twenty/fix/dashboard-native-print/packages/twenty-front/docs/print-screenshots/dashboard-after.png)

### Table records print

**Before**

![Table records print
before](https://raw.githubusercontent.com/vittolago/twenty/fix/dashboard-native-print/packages/twenty-front/docs/print-screenshots/tasks-before.png)

**After**

![Table records print
after](https://raw.githubusercontent.com/vittolago/twenty/fix/dashboard-native-print/packages/twenty-front/docs/print-screenshots/tasks-after.png)

## 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>
2026-06-29 22:41:37 +02:00

126 lines
3.2 KiB
TypeScript

import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { styled } from '@linaria/react';
import { AnimatePresence, motion } from 'framer-motion';
import { useLocation, useOutlet } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
const APP_TO_SETTINGS_TRANSITION_DURATION_IN_SECONDS = 0.3;
const SETTINGS_PATH_PREFIX = `/${AppPath.Settings}`;
const getMainAppLayoutRouteSection = (pathname: string) =>
pathname === SETTINGS_PATH_PREFIX ||
pathname.startsWith(`${SETTINGS_PATH_PREFIX}/`)
? 'settings'
: 'app';
const StyledRow = styled.div`
display: flex;
flex: 1;
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`
display: flex;
flex: 1 1 0;
min-height: 0;
min-width: 0;
overflow: hidden;
@media print {
display: block;
min-height: auto;
min-width: auto;
overflow: visible;
}
`;
const StyledContentTransitionContainer = styled.div`
display: grid;
flex: 1 1 0;
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)`
display: flex;
grid-area: 1 / 1;
min-height: 0;
min-width: 0;
width: 100%;
@media print {
display: block;
min-height: auto;
min-width: auto;
}
`;
const MainAppLayoutOutlet = () => {
const { pathname } = useLocation();
const outlet = useOutlet();
const routeSection = getMainAppLayoutRouteSection(pathname);
return (
<StyledContentTransitionContainer>
<AnimatePresence initial={false}>
<StyledContentTransitionPage
key={routeSection}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
// The outgoing page shares this grid cell with the incoming one, so it
// must not capture pointer/scroll events — including when AnimatePresence
// leaves a stale exit node mounted on top of the active page.
exit={{ opacity: 0, y: -4, pointerEvents: 'none' }}
transition={{
duration: APP_TO_SETTINGS_TRANSITION_DURATION_IN_SECONDS,
ease: 'easeInOut',
}}
>
{outlet}
</StyledContentTransitionPage>
</AnimatePresence>
</StyledContentTransitionContainer>
);
};
export const MainAppLayoutWithSidePanel = () => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
return (
<StyledRow>
<StyledContent>
<MainAppLayoutOutlet />
</StyledContent>
{isMobile ? <CommandMenuForMobile /> : <SidePanelForDesktop />}
</StyledRow>
);
};