snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
'use client';
import dynamic from 'next/dynamic';
import { KanbanPage } from './kanban/KanbanPage';
import { RecordPage } from './record/RecordPage';
import { TablePage } from './table/TablePage';
import { WorkflowPage } from './workflow/WorkflowPage';
import { type PageDefinition } from '../types';
// The dashboard (charts) is the heaviest page and never the landing view:
// it stays a deferred chunk, idle-preloaded by its hosts after mount.
const DashboardPage = dynamic(
() =>
import('./dashboard/DashboardPage').then((module) => module.DashboardPage),
{ ssr: false },
);
export function renderPage(page: PageDefinition) {
switch (page.type) {
case 'table':
return <TablePage page={page} />;
case 'kanban':
return <KanbanPage page={page} />;
case 'workflow':
return <WorkflowPage page={page} />;
case 'dashboard':
return <DashboardPage page={page} />;
case 'record':
return <RecordPage page={page} />;
default:
return null;
}
}

View File

@@ -0,0 +1,405 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useId, useState, type CSSProperties } from 'react';
import { createAnimationFrameLoop } from '@/platform/motion';
import { EASING, REDUCED_MOTION } from '@/tokens';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones';
import {
type DashboardBarChart as DashboardBarChartData,
type DashboardDonutChart as DashboardDonutChartData,
type DashboardLineChart as DashboardLineChartData,
} from '../../types';
const ACCENT = APP_PREVIEW_TONES.dashboardChart.accent;
const ChartFrame = styled.div`
display: flex;
flex: 1;
min-height: 0;
width: 100%;
`;
const AxisLabel = styled.span`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
font-size: 10px;
line-height: 1;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
`;
const PlotArea = styled.div`
display: flex;
flex: 1;
min-height: 0;
position: relative;
`;
const Gridlines = styled.div<{ $bottom: number }>`
bottom: ${({ $bottom }) => `${$bottom}px`};
display: flex;
flex-direction: column;
justify-content: space-between;
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
`;
const GridLine = styled.span`
border-top: 1px dashed ${THEME_LIGHT.border.color.light};
display: block;
width: 100%;
`;
const GRID_LINES = [0, 1, 2, 3];
const LINE_W = 320;
const LINE_H = 120;
const LINE_PAD = 10;
const LineSvg = styled.svg`
display: block;
height: 100%;
position: relative;
width: 100%;
z-index: 1;
.line-stroke {
transition: opacity 420ms ease;
}
.line-area {
transition: opacity 500ms ease 160ms;
}
${REDUCED_MOTION} {
.line-area,
.line-stroke {
transition: none;
}
}
`;
function DashboardLineChart({ data }: { data: DashboardLineChartData }) {
const [drawn, setDrawn] = useState(false);
const gradientId = `dashboard-line-fill-${useId().replace(/:/g, '')}`;
useEffect(() => {
const task = createAnimationFrameLoop({
onFrame: () => {
setDrawn(true);
return false;
},
});
task.start();
return () => task.stop();
}, []);
const max = Math.max(...data.values);
const min = Math.min(...data.values);
const range = max - min || 1;
const stepX = (LINE_W - LINE_PAD * 2) / (data.values.length - 1);
const points = data.values.map((value, index) => {
const x = LINE_PAD + index * stepX;
const y = LINE_PAD + (LINE_H - LINE_PAD * 2) * (1 - (value - min) / range);
return [x, y] as [number, number];
});
const linePath = points
.map(
([x, y], index) =>
`${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`,
)
.join(' ');
const lastX = points[points.length - 1][0];
const firstX = points[0][0];
const areaPath = `${linePath} L${lastX.toFixed(1)} ${LINE_H - LINE_PAD} L${firstX.toFixed(1)} ${LINE_H - LINE_PAD} Z`;
return (
<ChartFrame>
<PlotArea>
<Gridlines $bottom={0} aria-hidden>
{GRID_LINES.map((line) => (
<GridLine key={line} />
))}
</Gridlines>
<LineSvg
aria-hidden
preserveAspectRatio="none"
viewBox={`0 0 ${LINE_W} ${LINE_H}`}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={ACCENT} stopOpacity="0.22" />
<stop offset="100%" stopColor={ACCENT} stopOpacity="0" />
</linearGradient>
</defs>
<path
className="line-area"
d={areaPath}
fill={`url(#${gradientId})`}
style={{ opacity: drawn ? 1 : 0 }}
/>
<path
className="line-stroke"
d={linePath}
fill="none"
stroke={ACCENT}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
style={{ opacity: drawn ? 1 : 0 }}
vectorEffect="non-scaling-stroke"
/>
</LineSvg>
</PlotArea>
</ChartFrame>
);
}
const BarColumns = styled.div`
align-items: flex-end;
display: flex;
flex: 1;
gap: 10px;
min-height: 0;
padding-top: 8px;
position: relative;
width: 100%;
`;
const BarColumn = styled.div`
align-items: center;
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
justify-content: flex-end;
min-width: 0;
`;
const BarTrack = styled.div`
align-items: flex-end;
display: flex;
flex: 1;
min-height: 0;
width: 100%;
`;
const Bar = styled.div<{ $heightPct: number; $index: number }>`
animation: barGrow 460ms ${EASING.standard} both;
animation-delay: ${({ $index }) => `${$index * 90}ms`};
background: ${ACCENT};
border-radius: ${THEME_LIGHT.border.radius.sm} ${THEME_LIGHT.border.radius.sm}
0 0;
height: ${({ $heightPct }) => `${$heightPct}%`};
margin: 0 auto;
max-width: 28px;
transform-origin: bottom;
width: 100%;
@keyframes barGrow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
${REDUCED_MOTION} {
animation: none;
}
`;
function DashboardBarChart({ data }: { data: DashboardBarChartData }) {
const max = Math.max(...data.bars.map((bar) => bar.value)) || 1;
return (
<ChartFrame>
<PlotArea>
<Gridlines $bottom={22} aria-hidden>
{GRID_LINES.map((line) => (
<GridLine key={line} />
))}
</Gridlines>
<BarColumns>
{data.bars.map((bar, index) => (
<BarColumn key={bar.label}>
<BarTrack>
<Bar $heightPct={(bar.value / max) * 100} $index={index} />
</BarTrack>
<AxisLabel>{bar.label}</AxisLabel>
</BarColumn>
))}
</BarColumns>
</PlotArea>
</ChartFrame>
);
}
const DONUT_RADIUS = 15.915;
const DONUT_GAP = 2;
const DonutWrap = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: 12px;
min-height: 0;
`;
const DonutFigure = styled.div`
flex-shrink: 0;
height: 96px;
position: relative;
width: 96px;
`;
const DonutSvg = styled.svg`
display: block;
height: 100%;
transform: rotate(-90deg);
width: 100%;
.donut-slice {
animation: donutSliceDraw 520ms ${EASING.standard} both;
}
@keyframes donutSliceDraw {
from {
stroke-dasharray: 0 100;
}
to {
stroke-dasharray: var(--slice-pct) var(--slice-rest);
}
}
${REDUCED_MOTION} {
.donut-slice {
animation: none;
}
}
`;
const DonutCenter = styled.div`
align-items: center;
display: flex;
flex-direction: column;
inset: 0;
justify-content: center;
position: absolute;
`;
const DonutValue = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 22px;
font-weight: ${THEME_LIGHT.font.weight.semiBold};
line-height: 1;
`;
const DonutLabel = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 11px;
line-height: 1.4;
`;
const DonutLegend = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
`;
const LegendRow = styled.div`
align-items: center;
display: flex;
gap: 6px;
min-width: 0;
`;
const LegendDot = styled.span<{ $color: string }>`
background: ${({ $color }) => $color};
border-radius: ${THEME_LIGHT.border.radius.xs};
flex-shrink: 0;
height: 8px;
width: 8px;
`;
const LegendLabel = styled.span`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
function DashboardDonutChart({ data }: { data: DashboardDonutChartData }) {
const total = data.slices.reduce((sum, slice) => sum + slice.value, 0) || 1;
let cumulative = 0;
return (
<DonutWrap>
<DonutFigure>
<DonutSvg aria-hidden viewBox="0 0 36 36">
{data.slices.map((slice, index) => {
const pct = (slice.value / total) * 100;
const drawn = Math.max(pct - DONUT_GAP, 0.5);
const offset = -cumulative;
cumulative += pct;
return (
<circle
className="donut-slice"
cx="18"
cy="18"
fill="none"
key={slice.label}
pathLength={100}
r={DONUT_RADIUS}
stroke={slice.color}
strokeDasharray={`${drawn} ${100 - drawn}`}
strokeDashoffset={offset}
strokeLinecap="round"
strokeWidth="3.4"
style={
{
'--slice-pct': drawn,
'--slice-rest': 100 - drawn,
animationDelay: `${index * 120}ms`,
} as CSSProperties
}
/>
);
})}
</DonutSvg>
<DonutCenter>
<DonutValue>{data.centerValue}</DonutValue>
<DonutLabel>{data.centerLabel}</DonutLabel>
</DonutCenter>
</DonutFigure>
<DonutLegend>
{data.slices.map((slice) => (
<LegendRow key={slice.label}>
<LegendDot $color={slice.color} />
<LegendLabel>{slice.label}</LegendLabel>
</LegendRow>
))}
</DonutLegend>
</DonutWrap>
);
}
export const DASHBOARD_CHARTS = {
Line: DashboardLineChart,
Bar: DashboardBarChart,
Donut: DashboardDonutChart,
};

View File

@@ -0,0 +1,230 @@
import { styled } from '@linaria/react';
import { IconTrendingDown, IconTrendingUp } from '@tabler/icons-react';
import { EASING, mediaUp, REDUCED_MOTION } from '@/tokens';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { previewFontSize } from '@/app-preview/preview-font-size';
import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones';
import { DASHBOARD_CHARTS } from './DashboardCharts';
import { PREVIEW_SKELETON } from '../../primitives/PreviewSkeleton';
import { type DashboardKpi, type DashboardPageDefinition } from '../../types';
const DashboardGrid = styled.div`
display: grid;
gap: 8px;
grid-template-areas:
'kpis'
'line'
'bar'
'donut';
grid-template-columns: minmax(0, 1fr);
height: 100%;
min-height: 0;
padding: 8px;
${mediaUp('md')} {
grid-template-areas:
'kpis line line'
'bar bar donut';
grid-template-columns: minmax(176px, 248px) minmax(0, 1fr) minmax(
176px,
248px
);
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
`;
const WidgetCard = styled.div`
animation: dashboardWidgetAppear 360ms ${EASING.standard} both;
background: ${THEME_LIGHT.background.secondary};
border: 1px solid ${THEME_LIGHT.border.color.light};
border-radius: ${THEME_LIGHT.border.radius.md};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: hidden;
padding: 8px;
@keyframes dashboardWidgetAppear {
from {
opacity: 0;
transform: translateY(6px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
${REDUCED_MOTION} {
animation: none;
}
`;
const WidgetTitle = styled.span`
color: ${THEME_LIGHT.font.color.primary};
flex-shrink: 0;
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.medium};
line-height: 1.4;
overflow: hidden;
padding: 0 2px;
text-overflow: ellipsis;
white-space: nowrap;
`;
const KpiStack = styled.div`
display: grid;
gap: 8px;
grid-area: kpis;
min-width: 0;
${mediaUp('md')} {
grid-template-rows: repeat(3, minmax(0, 1fr));
}
`;
const KpiCard = styled(WidgetCard)`
gap: 6px;
justify-content: center;
`;
const KpiValueRow = styled.div`
align-items: baseline;
display: flex;
gap: 8px;
justify-content: space-between;
`;
const KpiValue = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 25px;
font-weight: ${THEME_LIGHT.font.weight.semiBold};
line-height: 1.1;
`;
const KpiTrend = styled.span<{ $up: boolean }>`
align-items: center;
color: ${({ $up }) =>
$up
? APP_PREVIEW_TONES.dashboardChart.trendUp
: APP_PREVIEW_TONES.dashboardChart.trendDown};
display: inline-flex;
flex-shrink: 0;
font-family: var(--font-product), sans-serif;
font-size: 12px;
font-weight: ${THEME_LIGHT.font.weight.medium};
gap: 2px;
`;
const LineCard = styled(WidgetCard)`
grid-area: line;
`;
const BarCard = styled(WidgetCard)`
grid-area: bar;
`;
const DonutCard = styled(WidgetCard)`
grid-area: donut;
`;
function KpiWidget({ kpi }: { kpi: DashboardKpi }) {
const isUp = kpi.trend?.direction === 'up';
return (
<KpiCard>
<WidgetTitle>{kpi.title}</WidgetTitle>
<KpiValueRow>
<KpiValue>{kpi.value}</KpiValue>
{kpi.trend ? (
<KpiTrend $up={isUp}>
{isUp ? (
<IconTrendingUp size={14} stroke={THEME_LIGHT.icon.stroke.md} />
) : (
<IconTrendingDown size={14} stroke={THEME_LIGHT.icon.stroke.md} />
)}
{kpi.trend.value}
</KpiTrend>
) : null}
</KpiValueRow>
</KpiCard>
);
}
function KpiSkeleton() {
return (
<KpiCard>
<PREVIEW_SKELETON.Bar $height={11} $width="55%" />
<PREVIEW_SKELETON.Bar $height={20} $width="45%" />
</KpiCard>
);
}
function ChartSkeleton() {
return (
<>
<PREVIEW_SKELETON.Bar $height={11} $width="48%" />
<PREVIEW_SKELETON.Block />
</>
);
}
export function DashboardPage({ page }: { page: DashboardPageDefinition }) {
const { kpis, lineChart, barChart, donutChart, generating } = page.dashboard;
return (
<DashboardGrid>
{kpis.length > 0 ? (
<KpiStack>
{kpis.map((kpi) =>
generating ? (
<KpiSkeleton key={kpi.id} />
) : (
<KpiWidget key={kpi.id} kpi={kpi} />
),
)}
</KpiStack>
) : null}
{lineChart ? (
<LineCard>
{generating ? (
<ChartSkeleton />
) : (
<>
<WidgetTitle>{lineChart.title}</WidgetTitle>
<DASHBOARD_CHARTS.Line data={lineChart} />
</>
)}
</LineCard>
) : null}
{barChart ? (
<BarCard>
{generating ? (
<ChartSkeleton />
) : (
<>
<WidgetTitle>{barChart.title}</WidgetTitle>
<DASHBOARD_CHARTS.Bar data={barChart} />
</>
)}
</BarCard>
) : null}
{donutChart ? (
<DonutCard>
{generating ? (
<ChartSkeleton />
) : (
<>
<WidgetTitle>{donutChart.title}</WidgetTitle>
<DASHBOARD_CHARTS.Donut data={donutChart} />
</>
)}
</DonutCard>
) : null}
</DashboardGrid>
);
}

View File

@@ -0,0 +1,292 @@
import { styled } from '@linaria/react';
import {
IconBuildingSkyscraper,
IconCalendarEvent,
IconCheck,
IconCurrencyDollar,
IconId,
IconStar,
IconUser,
IconUserCircle,
} from '@tabler/icons-react';
import { type ComponentType, type ReactNode } from 'react';
import { RatingStar } from '@/icons';
import { EASING } from '@/tokens';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { previewFontSize } from '@/app-preview/preview-font-size';
import { Chip } from '../../primitives/Chip';
import { FaviconLogo } from '../../primitives/FaviconLogo';
import { PersonAvatar } from '../../primitives/PersonAvatar';
import { PreviewAvatar } from '../../primitives/PreviewAvatar';
import {
type CellEntity,
type CellPerson,
type KanbanCard as KanbanCardData,
} from '../../types';
// twenty-front hashes a record's identifier to its avatar tone; the mockup
// does the same so each opportunity gets a stable, distinct color.
const TITLE_AVATAR_TONES = [
'blue',
'green',
'purple',
'pink',
'orange',
'red',
'amber',
'teal',
];
function toneForTitle(title: string): string {
const hash = [...title].reduce(
(total, char) => total + char.charCodeAt(0),
0,
);
return TITLE_AVATAR_TONES[hash % TITLE_AVATAR_TONES.length];
}
const Card = styled.div`
animation: kanbanCardAppear 320ms ${EASING.standard} both;
background: ${THEME_LIGHT.background.secondary};
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${THEME_LIGHT.border.radius.sm};
display: flex;
flex-direction: column;
overflow: hidden;
@keyframes kanbanCardAppear {
from {
opacity: 0;
transform: translateY(6px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
`;
const CardHeader = styled.div`
align-items: center;
display: flex;
gap: 8px;
padding: 8px 8px 4px;
`;
// The identifier renders as twenty-front's RecordChip: a rounded initial
// avatar (the record's hashed tone) + the name at regular weight in a
// transparent chip. The slot flexes so the name ellipsizes and the selection
// checkbox (only shown on selected cards) sits at the right.
const TitleSlot = styled.div`
display: flex;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
`;
const CheckboxContainer = styled.div`
align-items: center;
display: flex;
flex: 0 0 24px;
height: 24px;
justify-content: center;
width: 24px;
`;
const CheckboxBox = styled.div<{ $checked?: boolean }>`
align-items: center;
background: ${({ $checked }) =>
$checked ? THEME_LIGHT.background.transparent.blue : 'transparent'};
border: 1px solid
${({ $checked }) =>
$checked
? THEME_LIGHT.border.color.blue
: THEME_LIGHT.border.color.strong};
border-radius: 3px;
color: ${THEME_LIGHT.font.color.secondary};
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const CardFields = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 8px 8px 10px;
`;
const FieldRowShell = styled.div`
align-items: center;
display: flex;
gap: 4px;
min-height: 24px;
width: 100%;
`;
const FieldIcon = styled.div`
align-items: center;
color: ${THEME_LIGHT.font.color.tertiary};
display: flex;
flex: 0 0 16px;
height: 16px;
justify-content: center;
width: 16px;
`;
const FieldValueWrap = styled.div`
align-items: center;
display: flex;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
`;
const FieldText = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.regular};
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StarsRow = styled.div`
align-items: center;
display: inline-flex;
gap: 2px;
padding: 0 4px;
`;
const StarGlyph = styled.span`
align-items: center;
display: inline-flex;
height: 12px;
justify-content: center;
width: 12px;
`;
function EntityChip({ entity }: { entity: CellEntity }) {
return (
<Chip
clickable={false}
label={entity.name}
leftComponent={<FaviconLogo domain={entity.domain} label={entity.name} />}
maxWidth={152}
variant="highlighted"
/>
);
}
function PersonChip({ person }: { person: CellPerson }) {
return (
<Chip
clickable={false}
label={person.name}
leftComponent={<PersonAvatar person={person} />}
maxWidth={152}
variant="highlighted"
/>
);
}
function FieldRow({
icon: Icon,
children,
}: {
icon: ComponentType<{
'aria-hidden'?: boolean;
color?: string;
size?: number;
stroke?: number;
}>;
children: ReactNode;
}) {
return (
<FieldRowShell>
<FieldIcon>
<Icon
aria-hidden
color={THEME_LIGHT.font.color.tertiary}
size={16}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
</FieldIcon>
<FieldValueWrap>{children}</FieldValueWrap>
</FieldRowShell>
);
}
export function KanbanCard({ card }: { card: KanbanCardData }) {
return (
<Card>
<CardHeader>
<TitleSlot>
<Chip
clickable={false}
label={card.title}
leftComponent={
<PreviewAvatar tone={toneForTitle(card.title)}>
{card.title.trim().charAt(0).toUpperCase()}
</PreviewAvatar>
}
variant="transparent"
/>
</TitleSlot>
{card.checked ? (
<CheckboxContainer>
<CheckboxBox $checked>
<IconCheck
aria-hidden
color={THEME_LIGHT.font.color.secondary}
size={10}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
</CheckboxBox>
</CheckboxContainer>
) : null}
</CardHeader>
<CardFields>
<FieldRow icon={IconCurrencyDollar}>
<FieldText>{card.amount}</FieldText>
</FieldRow>
<FieldRow icon={IconBuildingSkyscraper}>
<EntityChip entity={card.company} />
</FieldRow>
<FieldRow icon={IconUserCircle}>
<PersonChip person={card.accountOwner} />
</FieldRow>
<FieldRow icon={IconStar}>
<StarsRow>
{Array.from({ length: 5 }, (_, index) => (
<StarGlyph key={index}>
<RatingStar
fillColor={
index < card.rating
? THEME_LIGHT.font.color.secondary
: THEME_LIGHT.border.color.strong
}
/>
</StarGlyph>
))}
</StarsRow>
</FieldRow>
<FieldRow icon={IconCalendarEvent}>
<FieldText>{card.date}</FieldText>
</FieldRow>
<FieldRow icon={IconUser}>
<PersonChip person={card.mainContact} />
</FieldRow>
<FieldRow icon={IconId}>
<FieldText>{card.recordId}</FieldText>
</FieldRow>
</CardFields>
</Card>
);
}

View File

@@ -0,0 +1,192 @@
import { styled } from '@linaria/react';
import { IconPlus } from '@tabler/icons-react';
import { EASING } from '@/tokens';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { previewFontSize } from '@/app-preview/preview-font-size';
import { KanbanCard } from './KanbanCard';
import { MiniIcon } from '../../primitives/MiniIcon';
import { PREVIEW_SKELETON } from '../../primitives/PreviewSkeleton';
import { type KanbanLane as KanbanLaneData } from '../../types';
const Lane = styled.div<{ $index: number; $last?: boolean }>`
animation: kanbanLaneAppear 420ms ${EASING.standard} both;
animation-delay: ${({ $index }) => `${120 + $index * 80}ms`};
border-right: ${({ $last }) =>
$last ? 'none' : `1px solid ${THEME_LIGHT.border.color.light}`};
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
@keyframes kanbanLaneAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const LaneHeader = styled.div`
align-items: center;
display: flex;
gap: 4px;
min-height: 40px;
padding: 8px;
`;
// A kanban column groups a Select field, so the product renders each column's
// stage as a Tag — color3 surface, color11 text, regular weight — keyed by the
// option color. The values bake from the generated theme (gray is the base and
// the unknown-tone fallback the old hand-mixed table carried).
const LaneTag = styled.span`
align-items: center;
background: ${THEME_LIGHT.tag.background.gray};
border-radius: ${THEME_LIGHT.border.radius.sm};
color: ${THEME_LIGHT.tag.text.gray};
display: inline-flex;
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.regular};
height: 20px;
line-height: 1.4;
padding: 0 8px;
white-space: nowrap;
&[data-tone='blue'] {
background: ${THEME_LIGHT.tag.background.blue};
color: ${THEME_LIGHT.tag.text.blue};
}
&[data-tone='green'] {
background: ${THEME_LIGHT.tag.background.green};
color: ${THEME_LIGHT.tag.text.green};
}
&[data-tone='pink'] {
background: ${THEME_LIGHT.tag.background.pink};
color: ${THEME_LIGHT.tag.text.pink};
}
&[data-tone='purple'] {
background: ${THEME_LIGHT.tag.background.purple};
color: ${THEME_LIGHT.tag.text.purple};
}
`;
const LaneCount = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.regular};
line-height: 1.4;
white-space: nowrap;
`;
const LaneBody = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 8px;
min-height: 0;
padding: 0 8px 8px;
`;
const AddCardButton = styled.div`
align-items: center;
color: ${THEME_LIGHT.font.color.tertiary};
display: inline-flex;
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.regular};
gap: 4px;
height: 24px;
line-height: 1.4;
padding: 0 4px;
white-space: nowrap;
`;
const SkeletonCardShell = styled.div<{ $index: number }>`
animation: kanbanSkeletonCardAppear 320ms ${EASING.standard} both;
animation-delay: ${({ $index }) => `${$index * 90}ms`};
background: ${THEME_LIGHT.background.secondary};
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${THEME_LIGHT.border.radius.sm};
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 8px;
@keyframes kanbanSkeletonCardAppear {
from {
opacity: 0;
transform: translateY(6px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
`;
const SkeletonCardField = styled.div`
align-items: center;
display: flex;
gap: 6px;
width: 100%;
`;
const SKELETON_FIELD_WIDTHS = ['70%', '54%', '62%'];
function SkeletonCard({ index }: { index: number }) {
return (
<SkeletonCardShell $index={index}>
<PREVIEW_SKELETON.Bar $height={9} $width="64%" />
{SKELETON_FIELD_WIDTHS.map((width) => (
<SkeletonCardField key={width}>
<PREVIEW_SKELETON.Circle $size={16} />
<PREVIEW_SKELETON.Bar $height={8} $width={width} />
</SkeletonCardField>
))}
</SkeletonCardShell>
);
}
export function KanbanLane({
lane,
index = 0,
isLast,
generating = false,
}: {
generating?: boolean;
index?: number;
isLast: boolean;
lane: KanbanLaneData;
}) {
const skeletonCardCount = 2 + (index % 2);
return (
<Lane $index={index} $last={isLast}>
<LaneHeader>
<LaneTag data-tone={lane.tone}>{lane.label}</LaneTag>
{generating ? null : <LaneCount>{lane.cards.length}</LaneCount>}
</LaneHeader>
<LaneBody>
{generating
? Array.from({ length: skeletonCardCount }, (_, cardIndex) => (
<SkeletonCard index={cardIndex} key={cardIndex} />
))
: lane.cards.map((card) => <KanbanCard card={card} key={card.id} />)}
<AddCardButton aria-hidden>
<MiniIcon
icon={IconPlus}
color={THEME_LIGHT.font.color.light}
size={12}
/>
New
</AddCardButton>
</LaneBody>
</Lane>
);
}

View File

@@ -0,0 +1,54 @@
import { styled } from '@linaria/react';
import { KanbanLane } from './KanbanLane';
import { type KanbanPageDefinition } from '../../types';
// The product's RECORD_BOARD_COLUMN_WIDTH.
const KANBAN_LANE_WIDTH_PX = 200;
const BoardShell = styled.div`
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: auto;
scrollbar-width: none;
width: 100%;
&::-webkit-scrollbar {
display: none;
}
`;
const BoardCanvas = styled.div<{ $laneCount: number }>`
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(
${({ $laneCount }) => $laneCount},
minmax(${KANBAN_LANE_WIDTH_PX}px, 1fr)
);
min-height: 100%;
min-width: ${({ $laneCount }) =>
`max(100%, ${$laneCount * KANBAN_LANE_WIDTH_PX + 16}px)`};
padding: 0 8px;
width: 100%;
`;
export function KanbanPage({ page }: { page: KanbanPageDefinition }) {
return (
<BoardShell
aria-label={`Interactive preview of the ${page.header.title} board`}
>
<BoardCanvas $laneCount={page.lanes.length}>
{page.lanes.map((lane, index) => (
<KanbanLane
generating={page.generating}
index={index}
isLast={index === page.lanes.length - 1}
key={lane.id}
lane={lane}
/>
))}
</BoardCanvas>
</BoardShell>
);
}

View File

@@ -0,0 +1,32 @@
import { styled } from '@linaria/react';
import { PersonAvatar } from '../../primitives/PersonAvatar';
import { type RecordParticipant } from '../../types';
const AvatarStack = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
`;
const AvatarWrap = styled.div<{ $index: number }>`
margin-left: ${({ $index }) => ($index === 0 ? '0' : '-4px')};
`;
export function AvatarGroup({
people,
size,
}: {
people: RecordParticipant[];
size: number;
}) {
return (
<AvatarStack>
{people.slice(0, 3).map((person, index) => (
<AvatarWrap $index={index} key={person.name}>
<PersonAvatar person={{ ...person, kind: 'person' }} size={size} />
</AvatarWrap>
))}
</AvatarStack>
);
}

View File

@@ -0,0 +1,169 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { IconArrowRight, IconPlus } from '@tabler/icons-react';
import { EASING } from '@/tokens';
import { type RecordCalendarDay } from '../../types';
import { AvatarGroup } from './AvatarGroup';
import { RECORD_PANEL_CHROME } from './RecordPanelChrome';
const CalendarDayRow = styled.div<{ $index: number }>`
align-items: flex-start;
animation: calendarDayAppear 360ms ${EASING.standard} both;
animation-delay: ${({ $index }) => `${120 + $index * 70}ms`};
display: flex;
gap: 12px;
padding: 8px 12px;
& + & {
border-top: 1px solid ${THEME_LIGHT.border.color.light};
}
@keyframes calendarDayAppear {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const DayBadge = styled.div`
flex-shrink: 0;
text-align: center;
width: 28px;
`;
const WeekDay = styled.div`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
`;
const MonthDay = styled.div`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 14px;
font-weight: 500;
`;
const DayEvents = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
min-width: 0;
`;
const CalEventRow = styled.div`
align-items: center;
display: flex;
gap: 12px;
height: 24px;
`;
const AttendanceBar = styled.span<{ $active?: boolean }>`
background: ${({ $active }) =>
$active ? THEME_LIGHT.accent.accent9 : THEME_LIGHT.border.color.strong};
border-radius: ${THEME_LIGHT.border.radius.xs};
flex-shrink: 0;
height: 24px;
width: 4px;
`;
const CalLabels = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: 8px;
min-width: 0;
`;
const CalTime = styled.div`
align-items: center;
color: ${THEME_LIGHT.font.color.tertiary};
display: flex;
flex-shrink: 0;
font-family: var(--font-product), sans-serif;
font-size: 13px;
gap: 4px;
`;
const CalTitle = styled.div`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const {
ListCard,
TabAddButton,
TabHeader,
TabHeaderCount,
TabHeaderLabel,
TabHeaderTitle,
TabSection,
} = RECORD_PANEL_CHROME;
export function RecordCalendar({
calendar,
}: {
calendar: RecordCalendarDay[];
}) {
return (
<TabSection>
<TabHeader>
<TabHeaderLabel>
<TabHeaderTitle>June</TabHeaderTitle>
<TabHeaderCount>
{calendar.reduce((total, day) => total + day.events.length, 0)}
</TabHeaderCount>
</TabHeaderLabel>
<TabAddButton>
<IconPlus size={14} stroke={2} />
Add event
</TabAddButton>
</TabHeader>
<ListCard>
{calendar.map((day, index) => (
<CalendarDayRow $index={index} key={day.id}>
<DayBadge>
<WeekDay>{day.weekday}</WeekDay>
<MonthDay>{day.day}</MonthDay>
</DayBadge>
<DayEvents>
{day.events.map((event) => (
<CalEventRow key={event.id}>
<AttendanceBar $active={event.attending} />
<CalLabels>
<CalTime>
{event.start}
<IconArrowRight
size={14}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
{event.end}
</CalTime>
<CalTitle>{event.title}</CalTitle>
</CalLabels>
<AvatarGroup people={event.participants} size={16} />
</CalEventRow>
))}
</DayEvents>
</CalendarDayRow>
))}
</ListCard>
</TabSection>
);
}

View File

@@ -0,0 +1,113 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { IconPlus } from '@tabler/icons-react';
import { type RecordEmail } from '../../types';
import { AvatarGroup } from './AvatarGroup';
import { RECORD_PANEL_CHROME } from './RecordPanelChrome';
const EmailHeading = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
max-width: 34%;
overflow: hidden;
`;
const SenderNames = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
margin: 0 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ThreadCount = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
`;
const SubjectBody = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: 8px;
overflow: hidden;
`;
const EmailSubject = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const EmailBody = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
flex: 1;
font-family: var(--font-product), sans-serif;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ReceivedAt = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
flex-shrink: 0;
font-family: var(--font-product), sans-serif;
font-size: 13px;
padding: 0 4px;
white-space: nowrap;
`;
const {
ActivityRowBox,
ListCard,
TabAddButton,
TabHeader,
TabHeaderCount,
TabHeaderLabel,
TabHeaderTitle,
TabSection,
} = RECORD_PANEL_CHROME;
export function RecordEmails({ emails }: { emails: RecordEmail[] }) {
return (
<TabSection>
<TabHeader>
<TabHeaderLabel>
<TabHeaderTitle>Inbox</TabHeaderTitle>
<TabHeaderCount>{emails.length}</TabHeaderCount>
</TabHeaderLabel>
<TabAddButton>
<IconPlus size={14} stroke={2} />
Compose
</TabAddButton>
</TabHeader>
<ListCard>
{emails.map((email, index) => (
<ActivityRowBox $index={index} key={email.id}>
<EmailHeading>
<AvatarGroup people={email.participants} size={16} />
<SenderNames>
{email.participants.map((person) => person.name).join(', ')}
</SenderNames>
<ThreadCount>{email.count}</ThreadCount>
</EmailHeading>
<SubjectBody>
<EmailSubject>{email.subject}</EmailSubject>
<EmailBody>{email.body}</EmailBody>
</SubjectBody>
<ReceivedAt>{email.date}</ReceivedAt>
</ActivityRowBox>
))}
</ListCard>
</TabSection>
);
}

View File

@@ -0,0 +1,123 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import {
IconBrandLinkedin,
IconBrandX,
IconCheck,
IconCurrencyDollar,
IconLink,
IconMapPin,
IconUser,
} from '@tabler/icons-react';
import { PersonAvatar } from '../../primitives/PersonAvatar';
import { PreviewRoundedLink } from '../../primitives/PreviewRoundedLink';
import { PreviewTag } from '../../primitives/PreviewTag';
import { type RecordField } from '../../types';
const FieldValueSlot = styled.div`
align-items: center;
display: flex;
flex: 1 1 auto;
min-height: 24px;
min-width: 0;
`;
const FieldValue = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const FieldValuePerson = styled.span`
align-items: center;
color: ${THEME_LIGHT.font.color.primary};
display: flex;
font-family: var(--font-product), sans-serif;
font-size: 13px;
gap: 4px;
line-height: 1.4;
min-width: 0;
`;
const FieldValuePersonName = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
function FieldIconGlyph({ iconName }: { iconName: string }) {
const stroke = THEME_LIGHT.icon.stroke.sm;
switch (iconName) {
case 'link':
return <IconLink aria-hidden size={16} stroke={stroke} />;
case 'user':
return <IconUser aria-hidden size={16} stroke={stroke} />;
case 'mapPin':
return <IconMapPin aria-hidden size={16} stroke={stroke} />;
case 'check':
return <IconCheck aria-hidden size={16} stroke={stroke} />;
case 'currency':
return <IconCurrencyDollar aria-hidden size={16} stroke={stroke} />;
case 'linkedin':
return <IconBrandLinkedin aria-hidden size={16} stroke={stroke} />;
case 'twitter':
return <IconBrandX aria-hidden size={16} stroke={stroke} />;
default:
return null;
}
}
function FieldValueRenderer({ field }: { field: RecordField }) {
switch (field.value.type) {
case 'text':
return (
<FieldValueSlot>
<FieldValue>{field.value.value}</FieldValue>
</FieldValueSlot>
);
case 'boolean':
return (
<FieldValueSlot>
<FieldValue>{field.value.value ? 'True' : 'False'}</FieldValue>
</FieldValueSlot>
);
case 'currency':
return (
<FieldValueSlot>
<FieldValue>{field.value.value}</FieldValue>
</FieldValueSlot>
);
case 'link':
return (
<FieldValueSlot>
<PreviewRoundedLink label={field.value.label ?? field.value.value} />
</FieldValueSlot>
);
case 'person':
return (
<FieldValueSlot>
<FieldValuePerson>
<PersonAvatar person={field.value} size={16} />
<FieldValuePersonName>{field.value.name}</FieldValuePersonName>
</FieldValuePerson>
</FieldValueSlot>
);
case 'select':
return (
<FieldValueSlot>
<PreviewTag color={field.value.color} label={field.value.value} />
</FieldValueSlot>
);
default:
return null;
}
}
export const recordFieldValue = { FieldIconGlyph, FieldValueRenderer };

View File

@@ -0,0 +1,118 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import {
IconCalendar,
IconDots,
IconFile,
IconFileText,
IconPlus,
IconTable,
} from '@tabler/icons-react';
import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones';
import { type RecordFile } from '../../types';
import { RECORD_PANEL_CHROME } from './RecordPanelChrome';
const FILE_ICONS: Record<
RecordFile['category'],
{ Icon: typeof IconFile; color: string }
> = {
pdf: { Icon: IconFileText, color: THEME_LIGHT.accent.accent9 },
doc: { Icon: IconFileText, color: THEME_LIGHT.accent.accent9 },
sheet: { Icon: IconTable, color: APP_PREVIEW_TONES.recordFileSheetInk },
other: { Icon: IconFile, color: THEME_LIGHT.font.color.tertiary },
};
const FileIconChip = styled.span<{ $color: string }>`
align-items: center;
background: ${({ $color }) => $color};
border-radius: ${THEME_LIGHT.border.radius.sm};
color: ${THEME_LIGHT.font.color.inverted};
display: flex;
flex-shrink: 0;
justify-content: center;
padding: 5px;
`;
const FileName = styled.span`
color: ${THEME_LIGHT.font.color.primary};
flex: 1;
font-family: var(--font-product), sans-serif;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const FileDate = styled.span`
align-items: center;
color: ${THEME_LIGHT.font.color.light};
display: flex;
flex-shrink: 0;
gap: 2px;
margin-left: auto;
`;
const FileDateText = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
white-space: nowrap;
`;
const FileDots = styled.span`
align-items: center;
color: ${THEME_LIGHT.font.color.tertiary};
display: flex;
flex-shrink: 0;
`;
const {
ActivityRowBox,
ListCard,
TabAddButton,
TabHeader,
TabHeaderCount,
TabHeaderLabel,
TabHeaderTitle,
TabSection,
} = RECORD_PANEL_CHROME;
export function RecordFiles({ files }: { files: RecordFile[] }) {
return (
<TabSection>
<TabHeader>
<TabHeaderLabel>
<TabHeaderTitle>All</TabHeaderTitle>
<TabHeaderCount>{files.length}</TabHeaderCount>
</TabHeaderLabel>
<TabAddButton>
<IconPlus size={14} stroke={2} />
Add file
</TabAddButton>
</TabHeader>
<ListCard>
{files.map((file, index) => {
const { Icon, color } = FILE_ICONS[file.category];
return (
<ActivityRowBox $index={index} key={file.id}>
<FileIconChip $color={color}>
<Icon size={14} stroke={THEME_LIGHT.icon.stroke.sm} />
</FileIconChip>
<FileName>{file.name}</FileName>
<FileDate>
<IconCalendar size={16} stroke={THEME_LIGHT.icon.stroke.sm} />
<FileDateText>{file.date}</FileDateText>
</FileDate>
<FileDots>
<IconDots size={16} stroke={THEME_LIGHT.icon.stroke.sm} />
</FileDots>
</ActivityRowBox>
);
})}
</ListCard>
</TabSection>
);
}

View File

@@ -0,0 +1,205 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { EASING } from '@/tokens';
import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones';
import { type RecordNote } from '../../types';
const NotesHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 16px;
margin-top: 16px;
padding: 0 24px;
`;
const NotesCount = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 600;
`;
const AddNoteButton = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
line-height: 1.4;
`;
const NotesGrid = styled.div`
display: grid;
gap: 16px;
grid-auto-rows: 1fr;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
overflow-y: auto;
padding: 0 24px 24px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
const NoteCard = styled.div<{
$highlighted?: boolean;
$index: number;
$muted?: boolean;
}>`
animation: noteCardAppear 360ms ${EASING.standard} both;
animation-delay: ${({ $index }) => `${120 + $index * 70}ms`};
background: ${({ $highlighted }) =>
$highlighted
? THEME_LIGHT.background.primary
: THEME_LIGHT.background.secondary};
border: 1px solid
${({ $highlighted }) =>
$highlighted
? THEME_LIGHT.border.color.medium
: THEME_LIGHT.border.color.light};
border-radius: ${THEME_LIGHT.border.radius.md};
display: flex;
flex-direction: column;
height: 300px;
justify-content: space-between;
opacity: ${({ $muted }) => ($muted ? 0.56 : 1)};
transform: ${({ $highlighted }) =>
$highlighted ? 'translateY(-2px)' : 'none'};
transition:
background 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
opacity 180ms ease,
transform 180ms ease;
box-shadow: ${({ $highlighted }) =>
$highlighted ? APP_PREVIEW_TONES.recordNoteHighlightShadow : 'none'};
@keyframes noteCardAppear {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const NoteContent = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 8px;
min-height: 0;
padding: 16px;
`;
const NoteTitle = styled.div`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.35;
`;
const NoteBody = styled.div`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre-line;
`;
const NoteRelation = styled.div`
align-items: center;
border-top: 1px solid ${THEME_LIGHT.border.color.light};
display: flex;
gap: 6px;
justify-content: center;
min-height: 37px;
padding: 8px;
`;
const NoteRelationArrow = styled.svg`
flex-shrink: 0;
height: 10px;
width: 10px;
`;
const NoteRelationLabel = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 12px;
line-height: 1.4;
`;
const NoteRelationName = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 12px;
line-height: 1.4;
`;
const NoteRelationAvatar = styled.img`
border-radius: 50%;
height: 16px;
object-fit: cover;
width: 16px;
`;
export function RecordNotes({ notes }: { notes: RecordNote[] }) {
const hasHighlightedNotes = notes.some((note) => note.highlighted);
return (
<>
<NotesHeader>
<NotesCount>All {notes.length}</NotesCount>
<AddNoteButton>+ Add note</AddNoteButton>
</NotesHeader>
<NotesGrid>
{notes.map((note, index) => (
<NoteCard
$highlighted={note.highlighted}
$index={index}
$muted={hasHighlightedNotes && !note.highlighted}
key={note.id}
>
<NoteContent>
<NoteTitle>{note.title}</NoteTitle>
<NoteBody>{note.body}</NoteBody>
</NoteContent>
{note.relation ? (
<NoteRelation>
<NoteRelationArrow
fill="none"
stroke={THEME_LIGHT.font.color.tertiary}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
viewBox="0 0 12 12"
>
<path d="M3.5 8.5L8.5 3.5M8.5 3.5H4.5M8.5 3.5V7.5" />
</NoteRelationArrow>
<NoteRelationLabel>Relations:</NoteRelationLabel>
{note.relation.avatarUrl ? (
<NoteRelationAvatar
alt={note.relation.name}
fetchPriority="low"
src={note.relation.avatarUrl}
/>
) : null}
<NoteRelationName>{note.relation.name}</NoteRelationName>
</NoteRelation>
) : null}
</NoteCard>
))}
</NotesGrid>
</>
);
}

View File

@@ -0,0 +1,459 @@
'use client';
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { IconChevronDown } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { EASING } from '@/tokens';
import { FaviconLogo } from '../../primitives/FaviconLogo';
import { type RecordPageDefinition } from '../../types';
import { recordFieldValue } from './RecordFieldValue';
import { RecordCalendar } from './RecordCalendar';
import { RecordEmails } from './RecordEmails';
import { RecordFiles } from './RecordFiles';
import { RecordNotes } from './RecordNotes';
import { RecordTasks } from './RecordTasks';
import { RecordTimeline } from './RecordTimeline';
import { recordTabs } from './record-tabs';
const Shell = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
width: 100%;
`;
const LeftPanel = styled.div`
border-right: 1px solid ${THEME_LIGHT.border.color.light};
display: flex;
flex: 0 0 248px;
flex-direction: column;
gap: 16px;
overflow-y: auto;
padding: 16px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
const RecordHeader = styled.div`
align-items: center;
animation: recordHeaderAppear 420ms ${EASING.standard} both;
animation-delay: 120ms;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 127px;
justify-content: center;
padding-bottom: 8px;
@keyframes recordHeaderAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const RecordName = styled.div`
font-family: var(--font-product), sans-serif;
font-size: 20px;
font-weight: 600;
color: ${THEME_LIGHT.font.color.primary};
line-height: 1.3;
text-align: center;
`;
const RecordMeta = styled.div`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
line-height: 1.4;
`;
const FieldList = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`;
const FieldRow = styled.div<{ $index: number }>`
align-items: flex-start;
animation: fieldRowAppear 420ms ${EASING.standard} both;
animation-delay: ${({ $index }) => `${190 + $index * 70}ms`};
display: flex;
gap: 8px;
min-height: 24px;
@keyframes fieldRowAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const FieldMeta = styled.div`
align-items: center;
color: ${THEME_LIGHT.font.color.tertiary};
display: flex;
flex: 0 0 110px;
gap: 4px;
min-height: 24px;
min-width: 0;
`;
const FieldIcon = styled.span`
align-items: center;
color: ${THEME_LIGHT.font.color.tertiary};
display: flex;
flex: 0 0 16px;
height: 16px;
justify-content: center;
width: 16px;
svg {
display: block;
height: 16px;
width: 16px;
}
`;
const FieldLabel = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 90px;
`;
const MoreToggle = styled.div`
align-items: center;
color: ${THEME_LIGHT.font.color.tertiary};
cursor: default;
display: flex;
font-family: var(--font-product), sans-serif;
font-size: 12px;
gap: 4px;
line-height: 1.4;
padding: 4px 0;
`;
const Divider = styled.div`
border-top: 1px solid ${THEME_LIGHT.border.color.light};
margin: 4px 0;
`;
const RelationSection = styled.div`
animation: relationAppear 420ms ${EASING.standard} both;
animation-delay: 600ms;
display: flex;
flex-direction: column;
gap: 6px;
@keyframes relationAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const RelationTitle = styled.div`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
`;
const RelationTitleCount = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-weight: 400;
margin-left: 4px;
`;
const RelationItem = styled.div<{ $highlighted?: boolean; $muted?: boolean }>`
align-items: center;
background: ${({ $highlighted }) =>
$highlighted ? THEME_LIGHT.background.secondary : 'transparent'};
border: 1px solid
${({ $highlighted }) =>
$highlighted ? THEME_LIGHT.border.color.medium : 'transparent'};
border-radius: 6px;
display: flex;
gap: 6px;
opacity: ${({ $muted }) => ($muted ? 0.55 : 1)};
padding: 2px 0;
padding-inline: ${({ $highlighted }) => ($highlighted ? '6px' : '0')};
transition:
background 180ms ease,
border-color 180ms ease,
opacity 180ms ease;
`;
const RelationName = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 12px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const RelationAvatarImage = styled.img`
border-radius: 50%;
flex: 0 0 auto;
height: 16px;
object-fit: cover;
width: 16px;
`;
const CenterPanel = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
overflow: hidden;
`;
const TabBar = styled.div`
align-items: center;
animation: tabBarAppear 260ms ease-out both;
animation-delay: 120ms;
border-bottom: 1px solid ${THEME_LIGHT.border.color.light};
display: flex;
flex: 0 0 auto;
gap: 4px;
min-height: 40px;
padding: 0 8px;
@keyframes tabBarAppear {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const Tab = styled.div<{ $active?: boolean; $clickable?: boolean }>`
all: unset;
align-items: center;
color: ${({ $active }) =>
$active
? THEME_LIGHT.font.color.primary
: THEME_LIGHT.font.color.secondary};
cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'default')};
display: flex;
position: relative;
text-decoration: none;
white-space: nowrap;
&::after {
background-color: ${({ $active }) =>
$active ? THEME_LIGHT.font.color.primary : 'transparent'};
bottom: 0;
content: '';
height: 1px;
left: 0;
position: absolute;
right: 0;
z-index: 1;
}
`;
const TabInner = styled.span`
display: flex;
font-family: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 500;
gap: 4px;
line-height: 1.4;
padding: 4px 8px;
border-radius: ${THEME_LIGHT.border.radius.sm};
color: inherit;
`;
const { FieldIconGlyph, FieldValueRenderer } = recordFieldValue;
export function RecordPage({ page }: { page: RecordPageDefinition }) {
const { record, notes, timeline, tasks, files, emails, calendar } = page;
const availableTabs = recordTabs.getAvailable(page);
const controlledTabLabel = page.activeTabLabel;
const isControlled = controlledTabLabel !== undefined;
const [activeTabIndex, setActiveTabIndex] = useState(0);
const [isInteractive, setIsInteractive] = useState(false);
useEffect(() => {
if (isControlled || isInteractive) {
return undefined;
}
if (activeTabIndex >= availableTabs.length - 1) {
setIsInteractive(true);
return undefined;
}
const timer = setTimeout(() => {
setActiveTabIndex((current) => current + 1);
}, recordTabs.DWELL_MS);
return () => clearTimeout(timer);
}, [availableTabs.length, activeTabIndex, isControlled, isInteractive]);
const activeTabLabel =
controlledTabLabel ?? availableTabs[activeTabIndex]?.label ?? 'Notes';
const handleTabClick = (label: string) => {
if (!isInteractive) {
return;
}
const nextIndex = availableTabs.findIndex((tab) => tab.label === label);
if (nextIndex >= 0) {
setActiveTabIndex(nextIndex);
}
};
const hasHighlightedRelations = record.relations.some((section) =>
section.items.some((item) => item.highlighted),
);
return (
<Shell>
<LeftPanel>
<RecordHeader>
<FaviconLogo
domain={record.logoDomain}
label={record.name}
size={48}
/>
<RecordName>{record.name}</RecordName>
<RecordMeta>{record.createdAt}</RecordMeta>
</RecordHeader>
<FieldList>
{record.fields.map((field, index) => (
<FieldRow $index={index} key={field.label}>
<FieldMeta>
<FieldIcon>
{field.icon ? <FieldIconGlyph iconName={field.icon} /> : null}
</FieldIcon>
<FieldLabel>{field.label}</FieldLabel>
</FieldMeta>
<FieldValueRenderer field={field} />
</FieldRow>
))}
</FieldList>
{record.moreCount ? (
<MoreToggle>
<IconChevronDown size={12} stroke={THEME_LIGHT.icon.stroke.sm} />
More ({record.moreCount})
</MoreToggle>
) : null}
<Divider />
{record.relations.map((section) => (
<RelationSection key={section.title}>
<RelationTitle>
{section.title}
{section.count ? (
<RelationTitleCount>All ({section.count})</RelationTitleCount>
) : null}
</RelationTitle>
{section.items.map((item) => (
<RelationItem
$highlighted={item.highlighted}
$muted={hasHighlightedRelations && !item.highlighted}
key={item.name}
>
{item.avatarUrl ? (
<RelationAvatarImage
alt={item.name}
fetchPriority="low"
src={item.avatarUrl}
/>
) : (
<FaviconLogo
domain={item.domain}
label={item.name}
size={16}
/>
)}
<RelationName>{item.name}</RelationName>
</RelationItem>
))}
</RelationSection>
))}
</LeftPanel>
<CenterPanel>
<TabBar>
{recordTabs.LIST.map((tab) => {
const isActive = tab.label === activeTabLabel;
return (
<Tab
key={tab.label}
$active={isActive}
$clickable={isInteractive}
data-record-tab={tab.label}
onClick={() => handleTabClick(tab.label)}
>
<TabInner>
<tab.Icon size={16} stroke={THEME_LIGHT.icon.stroke.sm} />
{tab.label}
</TabInner>
</Tab>
);
})}
</TabBar>
{activeTabLabel === 'Timeline' && timeline ? (
<RecordTimeline timeline={timeline} />
) : null}
{activeTabLabel === 'Tasks' && tasks ? (
<RecordTasks tasks={tasks} />
) : null}
{activeTabLabel === 'Notes' ? <RecordNotes notes={notes} /> : null}
{activeTabLabel === 'Files' && files ? (
<RecordFiles files={files} />
) : null}
{activeTabLabel === 'Emails' && emails ? (
<RecordEmails emails={emails} />
) : null}
{activeTabLabel === 'Calendar' && calendar ? (
<RecordCalendar calendar={calendar} />
) : null}
</CenterPanel>
</Shell>
);
}

View File

@@ -0,0 +1,102 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { EASING } from '@/tokens';
const TabSection = styled.div`
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 0 24px 24px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
const TabHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
margin: 16px 0;
`;
const TabHeaderLabel = styled.span`
align-items: baseline;
display: inline-flex;
`;
const TabHeaderTitle = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 600;
`;
const TabHeaderCount = styled.span`
color: ${THEME_LIGHT.font.color.light};
font-family: var(--font-product), sans-serif;
font-size: 13px;
margin-left: 8px;
`;
const TabAddButton = styled.span`
align-items: center;
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${THEME_LIGHT.border.radius.sm};
color: ${THEME_LIGHT.font.color.secondary};
display: inline-flex;
font-family: var(--font-product), sans-serif;
font-size: 13px;
gap: 4px;
height: 26px;
padding: 0 8px;
`;
const ListCard = styled.div`
background: ${THEME_LIGHT.background.secondary};
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${THEME_LIGHT.border.radius.md};
overflow: hidden;
`;
// Mirrors twenty-front's ActivityRow: 48px tall, 16px horizontal padding,
// 8px gap, sitting inside a bordered card with 1px dividers between rows.
const ActivityRowBox = styled.div<{ $index: number }>`
align-items: center;
animation: activityRowAppear 360ms ${EASING.standard} both;
animation-delay: ${({ $index }) => `${120 + $index * 70}ms`};
display: flex;
gap: 8px;
height: 48px;
padding: 0 16px;
& + & {
border-top: 1px solid ${THEME_LIGHT.border.color.light};
}
@keyframes activityRowAppear {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
// The activity-tab chrome every list panel (tasks, files, emails,
// calendar) composes: section scroller, counted header, bordered card.
export const RECORD_PANEL_CHROME = {
ActivityRowBox,
ListCard,
TabAddButton,
TabHeader,
TabHeaderCount,
TabHeaderLabel,
TabHeaderTitle,
TabSection,
};

View File

@@ -0,0 +1,153 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { IconCalendar, IconCheck, IconPlus } from '@tabler/icons-react';
import { FaviconLogo } from '../../primitives/FaviconLogo';
import { PersonAvatar } from '../../primitives/PersonAvatar';
import { type RecordTask } from '../../types';
import { RECORD_PANEL_CHROME } from './RecordPanelChrome';
const TaskLeft = styled.div`
align-items: center;
display: flex;
flex: 1;
overflow: hidden;
`;
const TaskCheckbox = styled.span<{ $done?: boolean }>`
align-items: center;
background: ${({ $done }) =>
$done ? THEME_LIGHT.accent.accent9 : 'transparent'};
border: 1px solid
${({ $done }) =>
$done ? THEME_LIGHT.accent.accent9 : THEME_LIGHT.font.color.primary};
border-radius: 50%;
color: ${THEME_LIGHT.font.color.inverted};
display: flex;
flex-shrink: 0;
height: 16px;
justify-content: center;
width: 16px;
`;
const TaskTitle = styled.span<{ $done?: boolean }>`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 500;
overflow: hidden;
padding: 0 8px;
text-decoration: ${({ $done }) => ($done ? 'line-through' : 'none')};
text-overflow: ellipsis;
white-space: nowrap;
`;
const TaskBody = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TaskRight = styled.div`
align-items: center;
display: inline-flex;
flex-shrink: 0;
gap: 8px;
margin-left: auto;
`;
const DueDate = styled.span`
align-items: center;
color: ${THEME_LIGHT.font.color.secondary};
display: flex;
font-family: var(--font-product), sans-serif;
font-size: 13px;
gap: 4px;
white-space: nowrap;
`;
const TargetChip = styled.span`
align-items: center;
background: ${THEME_LIGHT.background.secondary};
border: 1px solid ${THEME_LIGHT.border.color.light};
border-radius: 50px;
color: ${THEME_LIGHT.font.color.primary};
display: inline-flex;
font-family: var(--font-product), sans-serif;
font-size: 13px;
gap: 4px;
max-width: 160px;
padding: 1px 8px 1px 2px;
white-space: nowrap;
`;
const TargetChipName = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const {
ActivityRowBox,
ListCard,
TabAddButton,
TabHeader,
TabHeaderCount,
TabHeaderLabel,
TabHeaderTitle,
TabSection,
} = RECORD_PANEL_CHROME;
export function RecordTasks({ tasks }: { tasks: RecordTask[] }) {
return (
<TabSection>
<TabHeader>
<TabHeaderLabel>
<TabHeaderTitle>To do</TabHeaderTitle>
<TabHeaderCount>{tasks.length}</TabHeaderCount>
</TabHeaderLabel>
<TabAddButton>
<IconPlus size={14} stroke={2} />
Add task
</TabAddButton>
</TabHeader>
<ListCard>
{tasks.map((task, index) => (
<ActivityRowBox $index={index} key={task.id}>
<TaskLeft>
<TaskCheckbox $done={task.done}>
{task.done ? <IconCheck size={11} stroke={3} /> : null}
</TaskCheckbox>
<TaskTitle $done={task.done}>{task.title}</TaskTitle>
<TaskBody>{task.body}</TaskBody>
</TaskLeft>
<TaskRight>
<DueDate>
<IconCalendar size={16} stroke={THEME_LIGHT.icon.stroke.sm} />
{task.due}
</DueDate>
<TargetChip>
{task.target.domain ? (
<FaviconLogo
domain={task.target.domain}
label={task.target.name}
size={16}
/>
) : (
<PersonAvatar
person={{ ...task.target, kind: 'person' }}
size={16}
/>
)}
<TargetChipName>{task.target.name}</TargetChipName>
</TargetChip>
</TaskRight>
</ActivityRowBox>
))}
</ListCard>
</TabSection>
);
}

View File

@@ -0,0 +1,418 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import {
IconCalendarEvent,
IconChevronUp,
IconCirclePlus,
IconEditCircle,
IconNotes,
} from '@tabler/icons-react';
import { EASING } from '@/tokens';
import { PersonAvatar } from '../../primitives/PersonAvatar';
import { PreviewTag } from '../../primitives/PreviewTag';
import { type RecordFieldValue, type TimelineEvent } from '../../types';
const TimelineFeed = styled.div`
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 16px 24px 24px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
const MonthSeparator = styled.div`
align-items: center;
color: ${THEME_LIGHT.font.color.light};
display: flex;
font-family: var(--font-product), sans-serif;
font-size: 12px;
font-weight: 600;
gap: 16px;
margin-bottom: 16px;
`;
const MonthSeparatorLine = styled.div`
background: ${THEME_LIGHT.border.color.light};
border-radius: 50px;
flex: 1;
height: 1px;
`;
const TimelineGroup = styled.div`
position: relative;
`;
// The 24px rounded rail twenty-front renders behind the event icons.
const TimelineRail = styled.div`
background: ${THEME_LIGHT.background.secondary};
border: 1px solid ${THEME_LIGHT.border.color.light};
border-radius: ${THEME_LIGHT.border.radius.md};
bottom: 0;
left: 0;
position: absolute;
top: 0;
width: 24px;
z-index: 0;
`;
const TimelineRow = styled.div<{ $index: number }>`
animation: timelineRowAppear 360ms ${EASING.standard} both;
animation-delay: ${({ $index }) => `${120 + $index * 70}ms`};
display: flex;
gap: 12px;
position: relative;
z-index: 1;
@keyframes timelineRowAppear {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const TimelineGutter = styled.div`
align-items: center;
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 24px;
`;
const TimelineIconBox = styled.div`
align-items: center;
color: ${THEME_LIGHT.font.color.tertiary};
display: flex;
flex-shrink: 0;
height: 16px;
justify-content: center;
width: 16px;
`;
const TimelineConnector = styled.div<{ $hidden: boolean }>`
background: ${THEME_LIGHT.border.color.light};
flex: 1;
margin: 4px 0;
min-height: 12px;
opacity: ${({ $hidden }) => ($hidden ? 0 : 1)};
width: 2px;
`;
const TimelineMain = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
padding-bottom: 16px;
`;
const TimelineSummary = styled.div`
align-items: center;
display: flex;
gap: 4px;
justify-content: space-between;
min-height: 24px;
`;
const TimelineSummaryLeft = styled.div`
align-items: center;
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
`;
const TimelineActor = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
`;
const TimelineAction = styled.span`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
white-space: nowrap;
`;
const TimelineSubject = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
`;
const TimelineDiffLabel = styled.span`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
white-space: nowrap;
`;
const TimelineArrow = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
`;
const TimelineValueText = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
white-space: nowrap;
`;
const TimelineDiffPerson = styled.span`
align-items: center;
display: inline-flex;
gap: 4px;
`;
const TimelineLinkedTitle = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
overflow: hidden;
text-decoration: underline;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TimelineTime = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
flex-shrink: 0;
font-family: var(--font-product), sans-serif;
font-size: 13px;
padding-left: 8px;
white-space: nowrap;
`;
const TimelineToggle = styled.span`
align-items: center;
border: 1px solid ${THEME_LIGHT.border.color.light};
border-radius: ${THEME_LIGHT.border.radius.sm};
color: ${THEME_LIGHT.font.color.tertiary};
display: inline-flex;
flex-shrink: 0;
height: 20px;
justify-content: center;
width: 20px;
`;
const TimelineCardOuter = styled.div`
max-width: 360px;
padding-top: 2px;
width: 100%;
`;
const TimelineCardInner = styled.div`
background: ${THEME_LIGHT.background.secondary};
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${THEME_LIGHT.border.radius.md};
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
`;
const TimelineDiffRow = styled.div`
align-items: center;
display: flex;
gap: 4px;
min-height: 24px;
`;
const TimelineCardTitle = styled.div`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 500;
`;
const TimelineCardText = styled.div`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
line-height: 1.4;
`;
function TimelineEventIcon({ kind }: { kind: TimelineEvent['kind'] }) {
const stroke = THEME_LIGHT.icon.stroke.sm;
switch (kind) {
case 'created':
return <IconCirclePlus size={16} stroke={stroke} />;
case 'updated':
return <IconEditCircle size={16} stroke={stroke} />;
case 'note':
return <IconNotes size={16} stroke={stroke} />;
case 'calendar':
return <IconCalendarEvent size={16} stroke={stroke} />;
default:
return null;
}
}
function TimelineDiffValue({ value }: { value: RecordFieldValue }) {
switch (value.type) {
case 'select':
return <PreviewTag color={value.color} label={value.value} />;
case 'person':
return (
<TimelineDiffPerson>
<PersonAvatar person={value} size={16} />
<TimelineValueText>{value.name}</TimelineValueText>
</TimelineDiffPerson>
);
case 'boolean':
return (
<TimelineValueText>{value.value ? 'True' : 'False'}</TimelineValueText>
);
case 'currency':
case 'text':
return <TimelineValueText>{value.value}</TimelineValueText>;
case 'link':
return (
<TimelineValueText>{value.label ?? value.value}</TimelineValueText>
);
default:
return null;
}
}
function TimelineEventSummary({ event }: { event: TimelineEvent }) {
const stroke = THEME_LIGHT.icon.stroke.sm;
switch (event.kind) {
case 'created':
return (
<>
<TimelineSubject>{event.subject}</TimelineSubject>
<TimelineAction>was created by</TimelineAction>
<TimelineActor>{event.actor}</TimelineActor>
</>
);
case 'note':
return (
<>
<TimelineActor>{event.actor}</TimelineActor>
<TimelineAction>created a note</TimelineAction>
<TimelineLinkedTitle>{event.title}</TimelineLinkedTitle>
</>
);
case 'calendar':
return (
<>
<TimelineActor>{event.actor}</TimelineActor>
<TimelineAction>added a calendar event</TimelineAction>
<TimelineSubject>{event.title}</TimelineSubject>
<TimelineToggle>
<IconChevronUp size={12} stroke={stroke} />
</TimelineToggle>
</>
);
case 'updated':
if (event.diffs.length === 1) {
return (
<>
<TimelineActor>{event.actor}</TimelineActor>
<TimelineAction>updated</TimelineAction>
<TimelineDiffLabel>{event.diffs[0].label}</TimelineDiffLabel>
<TimelineArrow>→</TimelineArrow>
<TimelineDiffValue value={event.diffs[0].value} />
</>
);
}
return (
<>
<TimelineActor>{event.actor}</TimelineActor>
<TimelineAction>updated</TimelineAction>
<TimelineSubject>
{event.diffs.length} fields on {event.record}
</TimelineSubject>
<TimelineToggle>
<IconChevronUp size={12} stroke={stroke} />
</TimelineToggle>
</>
);
default:
return null;
}
}
function TimelineEventCard({ event }: { event: TimelineEvent }) {
if (event.kind === 'updated' && event.diffs.length > 1) {
return (
<TimelineCardOuter>
<TimelineCardInner>
{event.diffs.map((diff) => (
<TimelineDiffRow key={diff.label}>
<TimelineDiffLabel>{diff.label}</TimelineDiffLabel>
<TimelineArrow>→</TimelineArrow>
<TimelineDiffValue value={diff.value} />
</TimelineDiffRow>
))}
</TimelineCardInner>
</TimelineCardOuter>
);
}
if (event.kind === 'calendar') {
return (
<TimelineCardOuter>
<TimelineCardInner>
<TimelineCardTitle>{event.title}</TimelineCardTitle>
<TimelineCardText>{event.detail}</TimelineCardText>
</TimelineCardInner>
</TimelineCardOuter>
);
}
return null;
}
export function RecordTimeline({ timeline }: { timeline: TimelineEvent[] }) {
return (
<TimelineFeed>
<MonthSeparator>
Today
<MonthSeparatorLine />
</MonthSeparator>
<TimelineGroup>
<TimelineRail />
{timeline.map((event, index) => (
<TimelineRow $index={index} key={event.id}>
<TimelineGutter>
<TimelineIconBox>
<TimelineEventIcon kind={event.kind} />
</TimelineIconBox>
<TimelineConnector $hidden={index === timeline.length - 1} />
</TimelineGutter>
<TimelineMain>
<TimelineSummary>
<TimelineSummaryLeft>
<TimelineEventSummary event={event} />
</TimelineSummaryLeft>
<TimelineTime>{event.time}</TimelineTime>
</TimelineSummary>
<TimelineEventCard event={event} />
</TimelineMain>
</TimelineRow>
))}
</TimelineGroup>
</TimelineFeed>
);
}

View File

@@ -0,0 +1,106 @@
import { recordTabs } from './record-tabs';
import { type RecordPageDefinition } from '../../types';
const basePage: RecordPageDefinition = {
type: 'record',
header: { title: 'Anthropic' },
record: {
name: 'Anthropic',
createdAt: 'Added 3 months ago',
fields: [],
relations: [],
},
notes: [],
};
const note = { id: 'note-1', title: 'Kickoff', body: 'Notes body' };
describe('recordTabs.getAvailable', () => {
it('should expose only tabs whose panels have content', () => {
const page: RecordPageDefinition = {
...basePage,
notes: [note],
tasks: [
{
id: 'task-1',
title: 'Follow up',
body: 'Send recap',
due: 'Tomorrow',
target: { name: 'Anthropic', domain: 'anthropic.com' },
},
],
};
expect(recordTabs.getAvailable(page).map((tab) => tab.label)).toEqual([
'Tasks',
'Notes',
]);
});
it('should keep the authored tab order when everything is populated', () => {
const page: RecordPageDefinition = {
...basePage,
notes: [note],
timeline: [
{
kind: 'created',
id: 'event-1',
subject: 'Anthropic',
actor: 'Lucie',
time: '2:30 PM',
},
],
tasks: [
{
id: 'task-1',
title: 'Follow up',
body: 'Send recap',
due: 'Tomorrow',
target: { name: 'Anthropic' },
},
],
files: [
{ id: 'file-1', name: 'MSA.pdf', category: 'pdf', date: '2 Jun' },
],
emails: [
{
id: 'email-1',
participants: [{ name: 'Dario' }],
count: 3,
subject: 'Renewal',
body: 'Thanks for the call',
date: '2 Jun',
},
],
calendar: [
{
id: 'day-1',
weekday: 'Mon',
day: '9',
events: [
{
id: 'cal-1',
start: '9:00',
end: '9:30',
title: 'Sync',
participants: [{ name: 'Dario' }],
},
],
},
],
};
expect(recordTabs.getAvailable(page).map((tab) => tab.label)).toEqual([
'Timeline',
'Tasks',
'Notes',
'Files',
'Emails',
'Calendar',
]);
});
it('should return no tabs for an empty record', () => {
expect(recordTabs.getAvailable(basePage)).toEqual([]);
});
});

View File

@@ -0,0 +1,49 @@
import {
IconCalendarEvent,
IconCheckbox,
IconMail,
IconNotes,
IconPaperclip,
IconTimelineEvent,
} from '@tabler/icons-react';
import { type RecordPageDefinition } from '../../types';
export type RecordTabLabel =
| 'Timeline'
| 'Tasks'
| 'Notes'
| 'Files'
| 'Emails'
| 'Calendar';
type RecordTab = { label: RecordTabLabel; Icon: typeof IconNotes };
const LIST: readonly RecordTab[] = [
{ label: 'Timeline', Icon: IconTimelineEvent },
{ label: 'Tasks', Icon: IconCheckbox },
{ label: 'Notes', Icon: IconNotes },
{ label: 'Files', Icon: IconPaperclip },
{ label: 'Emails', Icon: IconMail },
{ label: 'Calendar', Icon: IconCalendarEvent },
];
const getAvailable = (page: RecordPageDefinition): readonly RecordTab[] => {
const hasContent: Record<RecordTabLabel, boolean> = {
Timeline: (page.timeline?.length ?? 0) > 0,
Tasks: (page.tasks?.length ?? 0) > 0,
Notes: page.notes.length > 0,
Files: (page.files?.length ?? 0) > 0,
Emails: (page.emails?.length ?? 0) > 0,
Calendar: (page.calendar?.length ?? 0) > 0,
};
return LIST.filter((tab) => hasContent[tab.label]);
};
// The tab strip auto-advances through every populated tab once (2400ms
// dwell per tab), then unlocks for the visitor's own clicks.
export const recordTabs = {
DWELL_MS: 2400,
LIST,
getAvailable,
};

View File

@@ -0,0 +1,335 @@
import { styled } from '@linaria/react';
import { IconCheck, IconCopy, IconPencil, IconX } from '@tabler/icons-react';
import { type ReactNode } from 'react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { previewFontSize } from '@/app-preview/preview-font-size';
import { TableCheckbox } from './TableCheckbox';
import { Chip, type ChipVariant } from '../../primitives/Chip';
import { FaviconLogo } from '../../primitives/FaviconLogo';
import { getInitials } from '../../primitives/get-initials';
import { MiniIcon } from '../../primitives/MiniIcon';
import { PersonAvatar } from '../../primitives/PersonAvatar';
import { PreviewAvatar } from '../../primitives/PreviewAvatar';
import { PreviewRoundedLink } from '../../primitives/PreviewRoundedLink';
import { PreviewTag } from '../../primitives/PreviewTag';
import {
type CellEntity,
type CellLink,
type CellPerson,
type CellRelation,
type CellText,
type CellValue,
} from '../../types';
// The floating row action sits over the cell's right padding.
const CELL_HORIZONTAL_PADDING = 8;
const HOVER_ACTION_EDGE_INSET = 4;
const ROW_HOVER_ACTION_DISABLED_COLUMNS = new Set([
'createdBy',
'accountOwner',
]);
const FirstColumnCellLayout = styled.div`
align-items: center;
display: flex;
gap: 4px;
height: 100%;
min-width: 0;
position: relative;
width: 100%;
`;
const CellHoverAnchor = styled.div`
align-items: center;
display: flex;
height: 100%;
min-width: 0;
position: relative;
width: 100%;
`;
const InlineText = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.regular};
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const RightAlignedText = styled(InlineText)`
text-align: right;
width: 100%;
`;
const BooleanRow = styled.div`
align-items: center;
display: inline-flex;
gap: 4px;
`;
const HoverActions = styled.div<{ $visible: boolean }>`
align-items: center;
background: ${THEME_LIGHT.background.transparent.primary};
border: 1px solid ${THEME_LIGHT.background.transparent.light};
border-radius: ${THEME_LIGHT.border.radius.sm};
bottom: 4px;
box-sizing: border-box;
box-shadow: ${THEME_LIGHT.boxShadow.light};
display: flex;
gap: 0;
justify-content: center;
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
padding: 0 4px;
pointer-events: none;
position: absolute;
right: ${HOVER_ACTION_EDGE_INSET - CELL_HORIZONTAL_PADDING}px;
top: 4px;
transform: translateX(${({ $visible }) => ($visible ? '0' : '4px')});
transition:
opacity 0.14s ease,
transform 0.14s ease;
width: 24px;
`;
const MiniAction = styled.div`
align-items: center;
border-radius: ${THEME_LIGHT.border.radius.xs};
color: ${THEME_LIGHT.font.color.secondary};
display: flex;
height: 16px;
justify-content: center;
width: 16px;
`;
const MultiChipStack = styled.div`
align-items: center;
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
width: 100%;
`;
function HoverAction({
icon,
visible,
}: {
icon: typeof IconCopy;
visible: boolean;
}) {
return (
<HoverActions $visible={visible}>
<MiniAction aria-hidden>
<MiniIcon icon={icon} color={THEME_LIGHT.font.color.secondary} />
</MiniAction>
</HoverActions>
);
}
function PersonTokenCell({
isFirstColumn = false,
token,
hovered = false,
variant = 'highlighted',
}: {
hovered?: boolean;
isFirstColumn?: boolean;
token: CellPerson;
variant?: ChipVariant;
}) {
const content = (
<Chip
clickable={false}
label={token.name}
leftComponent={<PersonAvatar person={token} />}
variant={variant}
/>
);
if (isFirstColumn) {
return (
<FirstColumnCellLayout>
<TableCheckbox />
{content}
<HoverAction icon={IconCopy} visible={hovered} />
</FirstColumnCellLayout>
);
}
return (
<CellHoverAnchor>
{content}
<HoverAction icon={IconCopy} visible={hovered} />
</CellHoverAnchor>
);
}
function EntityCellComponent({
cell,
hovered,
isFirstColumn,
}: {
cell: CellEntity;
hovered: boolean;
isFirstColumn: boolean;
}) {
const content = (
<Chip
clickable={false}
label={cell.name}
leftComponent={<FaviconLogo domain={cell.domain} label={cell.name} />}
variant="highlighted"
/>
);
if (isFirstColumn) {
return (
<FirstColumnCellLayout>
<TableCheckbox />
{content}
<HoverAction icon={IconPencil} visible={hovered} />
</FirstColumnCellLayout>
);
}
return (
<CellHoverAnchor>
{content}
<HoverAction icon={IconPencil} visible={hovered} />
</CellHoverAnchor>
);
}
function RelationCellComponent({ cell }: { cell: CellRelation }) {
return (
<CellHoverAnchor>
<MultiChipStack>
{cell.items.map((item) => (
<Chip
key={item.name}
clickable={false}
label={item.name}
leftComponent={
<PreviewAvatar tone={item.tone}>
{item.shortLabel ?? getInitials(item.name)}
</PreviewAvatar>
}
variant="highlighted"
/>
))}
</MultiChipStack>
</CellHoverAnchor>
);
}
function TextCellComponent({
cell,
isFirstColumn,
}: {
cell: CellText;
isFirstColumn: boolean;
}) {
if (!isFirstColumn) {
return <InlineText>{cell.value}</InlineText>;
}
const content = cell.shortLabel ? (
<Chip
clickable={false}
label={cell.value}
leftComponent={
<PreviewAvatar tone={cell.tone}>{cell.shortLabel}</PreviewAvatar>
}
variant="highlighted"
/>
) : (
<Chip clickable={false} label={cell.value} variant="highlighted" />
);
return (
<FirstColumnCellLayout>
<TableCheckbox />
{content}
</FirstColumnCellLayout>
);
}
function LinkCellComponent({ cell }: { cell: CellLink }) {
const label =
cell.label ??
(cell.kind === 'social' && cell.value.startsWith('@')
? cell.value
: cell.value);
return (
<CellHoverAnchor>
<PreviewRoundedLink label={label} />
</CellHoverAnchor>
);
}
export function renderTableCellValue({
cell,
columnId,
hovered,
isFirstColumn,
}: {
cell: CellValue;
columnId: string;
hovered: boolean;
isFirstColumn: boolean;
}): ReactNode {
const showHoverAction = !ROW_HOVER_ACTION_DISABLED_COLUMNS.has(columnId);
const personChipVariant: ChipVariant =
columnId === 'createdBy' ? 'transparent' : 'highlighted';
switch (cell.type) {
case 'text':
return <TextCellComponent cell={cell} isFirstColumn={isFirstColumn} />;
case 'number':
return <RightAlignedText>{cell.value}</RightAlignedText>;
case 'currency':
return <RightAlignedText>{cell.value}</RightAlignedText>;
case 'link':
return <LinkCellComponent cell={cell} />;
case 'boolean':
return (
<BooleanRow>
{cell.value ? (
<MiniIcon
icon={IconCheck}
color={THEME_LIGHT.font.color.primary}
size={11}
/>
) : (
<MiniIcon
icon={IconX}
color={THEME_LIGHT.font.color.primary}
size={11}
/>
)}
<InlineText>{cell.value ? 'True' : 'False'}</InlineText>
</BooleanRow>
);
case 'select':
return <PreviewTag color={cell.color} label={cell.value} />;
case 'person':
return (
<PersonTokenCell
hovered={hovered && showHoverAction}
isFirstColumn={isFirstColumn}
token={cell}
variant={personChipVariant}
/>
);
case 'entity':
return (
<EntityCellComponent
cell={cell}
hovered={hovered && showHoverAction}
isFirstColumn={isFirstColumn}
/>
);
case 'relation':
return <RelationCellComponent cell={cell} />;
}
}

View File

@@ -0,0 +1,43 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { type ReactNode } from 'react';
const CheckboxContainer = styled.div`
align-items: center;
display: flex;
flex: 0 0 24px;
height: 24px;
justify-content: center;
width: 24px;
`;
const CheckboxBox = styled.div<{ $checked?: boolean }>`
align-items: center;
background: ${({ $checked }) =>
$checked ? THEME_LIGHT.background.transparent.blue : 'transparent'};
border: 1px solid
${({ $checked }) =>
$checked
? THEME_LIGHT.border.color.blue
: THEME_LIGHT.font.color.primary};
border-radius: 3px;
display: flex;
flex: 0 0 auto;
height: 14px;
justify-content: center;
width: 14px;
`;
export function TableCheckbox({
checked,
children,
}: {
checked?: boolean;
children?: ReactNode;
}) {
return (
<CheckboxContainer>
<CheckboxBox $checked={checked}>{children}</CheckboxBox>
</CheckboxContainer>
);
}

View File

@@ -0,0 +1,44 @@
import {
IconBrandLinkedin,
IconBuildingFactory2,
IconCalendarEvent,
IconCreativeCommonsSa,
IconLink,
IconMap2,
IconMoneybag,
IconTarget,
IconTargetArrow,
IconUser,
IconUserCircle,
IconUsers,
} from '@tabler/icons-react';
import { type ReactNode } from 'react';
import { THEME_LIGHT } from 'twenty-ui/theme';
const HEADER_ICON_MAP: Record<string, typeof IconCalendarEvent> = {
added: IconCalendarEvent,
accountOwner: IconUserCircle,
address: IconMap2,
arr: IconMoneybag,
createdBy: IconCreativeCommonsSa,
employees: IconUsers,
icp: IconTarget,
industry: IconBuildingFactory2,
linkedin: IconBrandLinkedin,
mainContact: IconUser,
opportunities: IconTargetArrow,
url: IconLink,
};
export function renderTableHeaderIcon(columnId: string): ReactNode {
const Icon = HEADER_ICON_MAP[columnId] ?? IconCalendarEvent;
return (
<Icon
aria-hidden
color={THEME_LIGHT.font.color.tertiary}
size={16}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
);
}

View File

@@ -0,0 +1,388 @@
'use client';
import { styled } from '@linaria/react';
import { EASING } from '@/tokens';
import { IconChevronDown, IconPlus } from '@tabler/icons-react';
import { useState } from 'react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { previewFontSize } from '@/app-preview/preview-font-size';
import { APP_PREVIEW_CHROME } from '@/app-preview/app-preview-chrome';
import { renderTableCellValue } from './TableCellValue';
import { TableCheckbox } from './TableCheckbox';
import { renderTableHeaderIcon } from './TableHeaderIcon';
import { MiniIcon } from '../../primitives/MiniIcon';
import { PREVIEW_SKELETON } from '../../primitives/PreviewSkeleton';
import { useHorizontalDragScroll } from '@/platform/motion';
import { type TablePageDefinition } from '../../types';
const CELL_HORIZONTAL_PADDING = 8;
const TableShell = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
width: 100%;
`;
const TableViewport = styled.div<{ $dragging: boolean }>`
cursor: ${({ $dragging }) => ($dragging ? 'grabbing' : 'grab')};
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-width: none;
width: 100%;
&::-webkit-scrollbar {
display: none;
}
`;
const TableCanvas = styled.div<{ $width: number }>`
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
min-width: ${({ $width }) => `${$width}px`};
width: ${({ $width }) => `${$width}px`};
`;
const HeaderRow = styled.div`
animation: tableHeaderAppear 260ms ease-out both;
display: flex;
@keyframes tableHeaderAppear {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const DataRow = styled.div<{ $rowIndex: number }>`
animation: tableRowAppear 420ms ${EASING.standard} both;
animation-delay: ${({ $rowIndex }) => `${120 + $rowIndex * 70}ms`};
display: flex;
max-height: 0;
/* Axis-split clip: the reveal animation clips VERTICALLY only. Unlike
overflow:hidden, clip never creates a scroll container, so the
sticky first column holds at every instant — including mid-reveal.
The product pins this column (twenty-front's record table); the old
mockup ships it broken (user-ratified improvement). */
overflow-x: visible;
overflow-y: clip;
@keyframes tableRowAppear {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: ${APP_PREVIEW_CHROME.recordTableRowHeightPx}px;
}
}
`;
const FooterRow = styled.div`
display: flex;
`;
const TableCell = styled.div<{
$align?: 'left' | 'right';
$header?: boolean;
$hovered?: boolean;
$sticky?: boolean;
$width: number;
}>`
align-items: center;
background: ${({ $header, $hovered }) => {
if ($header) {
return THEME_LIGHT.background.primary;
}
return $hovered
? THEME_LIGHT.background.secondary
: THEME_LIGHT.background.primary;
}};
border-bottom: 1px solid ${THEME_LIGHT.border.color.light};
border-right: 1px solid ${THEME_LIGHT.border.color.light};
box-sizing: border-box;
display: flex;
flex: 0 0 ${({ $width }) => `${$width}px`};
height: ${APP_PREVIEW_CHROME.recordTableRowHeightPx}px;
justify-content: ${({ $align }) =>
$align === 'right' ? 'flex-end' : 'flex-start'};
left: ${({ $sticky }) => ($sticky ? '0' : 'auto')};
min-width: ${({ $width }) => `${$width}px`};
padding-bottom: 0;
padding-right: ${CELL_HORIZONTAL_PADDING}px;
padding-top: 0;
padding-left: ${({ $sticky }) =>
$sticky
? `${CELL_HORIZONTAL_PADDING - 1}px`
: `${CELL_HORIZONTAL_PADDING}px`};
position: ${({ $sticky }) => ($sticky ? 'sticky' : 'relative')};
z-index: ${({ $header, $sticky }) => {
if ($sticky && $header) {
return 6;
}
if ($sticky) {
return 4;
}
return 1;
}};
`;
const EmptyFillCell = styled.div<{
$footer?: boolean;
$header?: boolean;
$hovered?: boolean;
$width: number;
}>`
background: ${({ $header, $hovered, $footer }) => {
if ($header || $footer) {
return THEME_LIGHT.background.primary;
}
return $hovered
? THEME_LIGHT.background.secondary
: THEME_LIGHT.background.primary;
}};
border-bottom: 1px solid ${THEME_LIGHT.border.color.light};
flex: 0 0 ${({ $width }) => `${$width}px`};
min-width: ${({ $width }) => `${$width}px`};
`;
const HeaderCellContent = styled.div`
align-items: center;
display: flex;
gap: 4px;
height: 100%;
min-width: 0;
width: 100%;
`;
const HeaderLabel = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.medium};
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const MutedText = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.regular};
line-height: 1.4;
white-space: nowrap;
`;
const FooterFirstContent = styled.div`
align-items: center;
display: inline-flex;
gap: 4px;
padding-left: 28px;
`;
const HeaderFillContent = styled.div`
align-items: center;
display: flex;
height: 100%;
padding: 0 8px;
`;
const EdgePlus = styled.div`
margin-left: auto;
`;
const SKELETON_ROW_INDEXES = [0, 1, 2, 3, 4, 5, 6];
const SkeletonRowLead = styled.div`
align-items: center;
display: flex;
gap: 6px;
width: 100%;
`;
export function TablePage({ page }: { page: TablePageDefinition }) {
const {
dragging,
onPointerCancel,
onPointerDown,
onPointerLeave,
onPointerMove,
onPointerUp,
viewportRef,
} = useHorizontalDragScroll<HTMLDivElement>();
const [hoveredRowId, setHoveredRowId] = useState<string | null>(null);
const columnWidth = page.columns.reduce(
(total, column) => total + column.width,
0,
);
const totalTableWidth = page.width ?? columnWidth;
const fillerWidth = Math.max(totalTableWidth - columnWidth, 0);
return (
<TableShell>
<TableViewport
$dragging={dragging}
onPointerCancel={onPointerCancel}
onPointerDown={onPointerDown}
onPointerLeave={onPointerLeave}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
ref={viewportRef}
>
<TableCanvas $width={totalTableWidth}>
<HeaderRow>
{page.columns.map((column) => (
<TableCell
$align={column.align}
$header
$sticky={column.isFirstColumn}
$width={column.width}
key={column.id}
>
<HeaderCellContent>
{column.isFirstColumn ? (
<>
<TableCheckbox />
{renderTableHeaderIcon(column.id)}
<HeaderLabel>{column.label}</HeaderLabel>
<EdgePlus aria-hidden>
<MiniIcon
icon={IconPlus}
color={THEME_LIGHT.font.color.tertiary}
size={12}
/>
</EdgePlus>
</>
) : (
<>
{renderTableHeaderIcon(column.id)}
<HeaderLabel>{column.label}</HeaderLabel>
</>
)}
</HeaderCellContent>
</TableCell>
))}
<EmptyFillCell $header $width={fillerWidth}>
{fillerWidth > 0 ? (
<HeaderFillContent>
<MiniIcon
icon={IconPlus}
color={THEME_LIGHT.font.color.tertiary}
size={16}
/>
</HeaderFillContent>
) : null}
</EmptyFillCell>
</HeaderRow>
{page.generating
? SKELETON_ROW_INDEXES.map((rowIndex) => (
<DataRow $rowIndex={rowIndex} key={`skeleton-${rowIndex}`}>
{page.columns.map((column) => (
<TableCell
$align={column.align}
$sticky={column.isFirstColumn}
$width={column.width}
key={column.id}
>
{column.isFirstColumn ? (
<SkeletonRowLead>
<PREVIEW_SKELETON.Circle $size={16} />
<PREVIEW_SKELETON.Bar $height={8} $width="58%" />
</SkeletonRowLead>
) : (
<PREVIEW_SKELETON.Bar $height={8} $width="56%" />
)}
</TableCell>
))}
<EmptyFillCell $width={fillerWidth} />
</DataRow>
))
: page.rows.map((row, rowIndex) => {
const hovered = hoveredRowId === row.id;
return (
<DataRow
$rowIndex={rowIndex}
data-row-id={row.id}
key={row.id}
onMouseEnter={() => setHoveredRowId(row.id)}
onMouseLeave={() =>
setHoveredRowId((current) =>
current === row.id ? null : current,
)
}
>
{page.columns.map((column) => {
const cell = row.cells[column.id];
return (
<TableCell
$align={column.align}
$hovered={hovered}
$sticky={column.isFirstColumn}
$width={column.width}
key={`${row.id}-${column.id}`}
>
{cell
? renderTableCellValue({
cell,
columnId: column.id,
hovered,
isFirstColumn: !!column.isFirstColumn,
})
: null}
</TableCell>
);
})}
<EmptyFillCell $hovered={hovered} $width={fillerWidth} />
</DataRow>
);
})}
<FooterRow>
{page.columns.length > 0 ? (
<TableCell
$sticky={page.columns[0].isFirstColumn}
$width={page.columns[0].width}
>
<FooterFirstContent>
<MutedText>Calculate</MutedText>
<MiniIcon
icon={IconChevronDown}
color={THEME_LIGHT.font.color.tertiary}
/>
</FooterFirstContent>
</TableCell>
) : null}
{page.columns.slice(1).map((column) => (
<TableCell
$align={column.align}
$width={column.width}
key={`footer-${column.id}`}
/>
))}
<EmptyFillCell $footer $width={fillerWidth} />
</FooterRow>
</TableCanvas>
</TableViewport>
</TableShell>
);
}

View File

@@ -0,0 +1,55 @@
import { styled } from '@linaria/react';
import { REDUCED_MOTION } from '@/tokens';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { WORKFLOW_THEME } from './workflow-theme';
import { type WorkflowBranchLabelDef } from '../../types';
const colors = WORKFLOW_THEME.colors;
const BranchLabelPill = styled.div<{ $centered?: boolean }>`
align-items: center;
animation: workflowBranchLabelAppear 320ms ease both;
animation-delay: 700ms;
background: ${colors.nodeSurface};
border: 1px solid ${colors.nodeBorder};
border-radius: ${THEME_LIGHT.border.radius.sm};
color: ${colors.textLight};
display: inline-flex;
font-family: var(--font-product), sans-serif;
font-size: 11px;
font-weight: ${THEME_LIGHT.font.weight.semiBold};
height: 20px;
justify-content: center;
min-width: 20px;
padding: 0 4px;
position: absolute;
transform: ${({ $centered }) =>
$centered ? 'translate(-50%, -50%)' : 'none'};
z-index: 2;
@keyframes workflowBranchLabelAppear {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
${REDUCED_MOTION} {
animation: none;
}
`;
export function WorkflowBranchLabel({ text, x, y }: WorkflowBranchLabelDef) {
return (
<BranchLabelPill
$centered={text === 'completed'}
style={{ left: x, top: y }}
>
{text}
</BranchLabelPill>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { styled } from '@linaria/react';
import { useId } from 'react';
import { EASING, REDUCED_MOTION } from '@/tokens';
import {
getWorkflowEdgePath,
type PositionedWorkflowNode,
} from './workflow-geometry';
import { WORKFLOW_THEME } from './workflow-theme';
import { type WorkflowEdgeDef } from '../../types';
const colors = WORKFLOW_THEME.colors;
const NODE_HEIGHT = WORKFLOW_THEME.nodeHeightPx;
const CanvasOverlay = styled.svg`
inset: 0;
overflow: visible;
pointer-events: none;
position: absolute;
`;
const EdgePath = styled.path`
animation: workflowEdgeDraw 620ms ${EASING.standard} both;
stroke-dasharray: 1;
@keyframes workflowEdgeDraw {
from {
opacity: 0;
stroke-dashoffset: 1;
}
25% {
opacity: 1;
}
to {
opacity: 1;
stroke-dashoffset: 0;
}
}
${REDUCED_MOTION} {
animation: none;
}
`;
const PortsLayer = styled.g`
animation: workflowPortsAppear 360ms ease both;
animation-delay: 520ms;
@keyframes workflowPortsAppear {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
${REDUCED_MOTION} {
animation: none;
}
`;
export function WorkflowEdges({
canvasHeight,
canvasWidth,
edges,
nodes,
}: {
canvasHeight: number;
canvasWidth: number;
edges: ReadonlyArray<WorkflowEdgeDef>;
nodes: ReadonlyArray<PositionedWorkflowNode>;
}) {
const arrowId = `workflow-arrow-${useId().replace(/:/g, '')}`;
const topPortNodeIds = new Set<string>();
const bottomPortNodeIds = new Set<string>();
const rightPortNodeIds = new Set<string>();
for (const edge of edges) {
if (edge.type === 'loopRight') {
rightPortNodeIds.add(edge.from);
topPortNodeIds.add(edge.to);
continue;
}
bottomPortNodeIds.add(edge.from);
if (edge.type !== 'loopBack') {
topPortNodeIds.add(edge.to);
}
}
return (
<CanvasOverlay aria-hidden viewBox={`0 0 ${canvasWidth} ${canvasHeight}`}>
<defs>
<marker
id={arrowId}
markerHeight="6"
markerWidth="6"
orient="auto"
refX="5"
refY="3"
>
<path
d="M0.75 0.75 L5 3 L0.75 5.25"
fill="none"
stroke={colors.arrowStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.1"
/>
</marker>
</defs>
{edges.map((edge, index) => (
<EdgePath
d={getWorkflowEdgePath({ edge, nodes })}
fill="none"
key={`${edge.from}-${edge.to}-${edge.type}`}
markerEnd={edge.type === 'loopBack' ? undefined : `url(#${arrowId})`}
pathLength={1}
stroke={colors.arrowStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
style={{ animationDelay: `${140 + index * 150}ms` }}
/>
))}
<PortsLayer>
{nodes.map((node) => {
const cx = node.x + node.width / 2;
const bottomY = node.y + NODE_HEIGHT + 1;
const rightX = node.x + node.width;
const midY = node.y + NODE_HEIGHT / 2;
return (
<g key={`ports-${node.id}`}>
{topPortNodeIds.has(node.id) ? (
<circle
cx={cx}
cy={node.y}
fill={colors.nodeSurface}
r="4"
stroke={colors.nodeBorder}
strokeWidth="1"
/>
) : null}
{bottomPortNodeIds.has(node.id) ? (
<circle
cx={cx}
cy={bottomY}
fill={colors.nodeSurface}
r="4"
stroke={colors.nodeBorder}
strokeWidth="1"
/>
) : null}
{rightPortNodeIds.has(node.id) ? (
<circle
cx={rightX}
cy={midY}
fill={colors.nodeSurface}
r="4"
stroke={colors.nodeBorder}
strokeWidth="1"
/>
) : null}
</g>
);
})}
</PortsLayer>
</CanvasOverlay>
);
}

View File

@@ -0,0 +1,183 @@
import { styled } from '@linaria/react';
import {
IconClock,
IconCode,
IconFilter,
IconMail,
IconPlug,
IconPlus,
IconRepeat,
IconSearch,
IconSitemap,
} from '@tabler/icons-react';
import { EASING, REDUCED_MOTION } from '@/tokens';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { previewFontSize } from '@/app-preview/preview-font-size';
import { WORKFLOW_THEME } from './workflow-theme';
import { PREVIEW_SKELETON } from '../../primitives/PreviewSkeleton';
import { type WorkflowNodeDef } from '../../types';
const colors = WORKFLOW_THEME.colors;
const NODE_ICON_MAP: Record<string, typeof IconPlug> = {
clock: IconClock,
code: IconCode,
filter: IconFilter,
mail: IconMail,
plug: IconPlug,
plus: IconPlus,
repeat: IconRepeat,
search: IconSearch,
sitemap: IconSitemap,
};
const NODE_ICON_COLORS: Record<string, string> = {
trigger: colors.nodeTriggerIcon,
action: colors.nodeActionIcon,
fallback: colors.nodeIconFallback,
};
const Node = styled.div<{ $index: number }>`
align-items: center;
animation: workflowNodeAppear 420ms ${EASING.standard} both;
animation-delay: ${({ $index }) => `${150 + $index * 120}ms`};
background: ${colors.nodeSurface};
border: 1px solid ${colors.nodeBorder};
border-radius: ${THEME_LIGHT.border.radius.md};
box-sizing: border-box;
display: flex;
gap: 8px;
height: ${WORKFLOW_THEME.nodeHeightPx}px;
left: 0;
padding: 8px;
position: absolute;
top: 0;
z-index: 1;
@keyframes workflowNodeAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const NodeIconContainer = styled.div`
align-items: center;
animation: workflowNodeContentAppear 320ms ease both;
animation-delay: 80ms;
background: ${colors.nodeIconSurface};
border-radius: ${THEME_LIGHT.border.radius.sm};
display: flex;
flex: 0 0 auto;
height: 32px;
justify-content: center;
width: 32px;
@keyframes workflowNodeContentAppear {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
${REDUCED_MOTION} {
animation: none;
}
`;
const NodeContent = styled.div`
align-items: stretch;
align-self: stretch;
animation: workflowNodeContentAppear 320ms ease both;
animation-delay: 80ms;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: space-between;
max-width: 184px;
min-width: 0;
padding-bottom: 2px;
${REDUCED_MOTION} {
animation: none;
}
`;
const NodeLabel = styled.div`
color: ${colors.textLight};
font-family: var(--font-product), sans-serif;
font-size: 11px;
font-weight: ${THEME_LIGHT.font.weight.semiBold};
line-height: 1;
`;
const NodeTitle = styled.div`
color: ${colors.textPrimary};
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.medium};
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const SkeletonIcon = styled(PREVIEW_SKELETON.Block)`
border-radius: ${THEME_LIGHT.border.radius.sm};
flex: 0 0 auto;
height: 32px;
width: 32px;
`;
export function WorkflowNode({
generating = false,
index = 0,
node,
}: {
generating?: boolean;
index?: number;
node: WorkflowNodeDef;
}) {
const Icon = NODE_ICON_MAP[node.iconName] ?? IconCode;
const iconColor = NODE_ICON_COLORS[node.iconColor ?? 'action'];
return (
<Node
$index={index}
style={{ left: node.x, top: node.y, width: node.width }}
>
{generating ? (
<>
<SkeletonIcon />
<NodeContent>
<PREVIEW_SKELETON.Bar $height={8} $width="38%" />
<PREVIEW_SKELETON.Bar $height={9} $width="72%" />
</NodeContent>
</>
) : (
<>
<NodeIconContainer>
<Icon
aria-hidden
color={iconColor}
size={20}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
</NodeIconContainer>
<NodeContent>
<NodeLabel>{node.label}</NodeLabel>
<NodeTitle>{node.title}</NodeTitle>
</NodeContent>
</>
)}
</Node>
);
}

View File

@@ -0,0 +1,147 @@
import { styled } from '@linaria/react';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { previewFontSize } from '@/app-preview/preview-font-size';
import { WORKFLOW_GRAPH } from './workflow-data';
import { WorkflowBranchLabel } from './WorkflowBranchLabel';
import { WorkflowEdges } from './WorkflowEdges';
import { WorkflowNode } from './WorkflowNode';
import { WORKFLOW_THEME } from './workflow-theme';
import { type WorkflowPageDefinition } from '../../types';
const colors = WORKFLOW_THEME.colors;
const PageShell = styled.div`
background: ${colors.canvasBackground};
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 100%;
min-width: 100%;
`;
const CanvasViewportShell = styled.div`
background: ${colors.canvasBackground};
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
position: relative;
`;
const CanvasViewport = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: auto;
`;
const Canvas = styled.div<{ $height: number; $width: number }>`
background-color: ${colors.canvasBackground};
background-image: radial-gradient(
circle,
${colors.canvasDot} 1px,
transparent 1.2px
);
background-position: 10px 10px;
background-size: 20px 20px;
box-sizing: border-box;
height: ${({ $height }) => `${$height}px`};
min-height: 100%;
min-width: 100%;
overflow: hidden;
position: relative;
width: ${({ $width }) => `${$width}px`};
`;
const CanvasContent = styled.div<{ $height: number; $width: number }>`
height: ${({ $height }) => `${$height}px`};
left: calc((100% - ${({ $width }) => `${$width}px`}) / 2);
position: absolute;
top: ${WORKFLOW_THEME.canvasTopOffsetPx}px;
width: ${({ $width }) => `${$width}px`};
`;
const ActiveBadge = styled.div`
left: 8px;
pointer-events: none;
position: absolute;
top: 8px;
z-index: 3;
`;
const ActiveBadgeLabel = styled.span`
align-items: center;
background: ${colors.activeBadgeBackground};
border-radius: ${THEME_LIGHT.border.radius.sm};
color: ${colors.activeBadgeText};
display: inline-flex;
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.regular};
height: 20px;
line-height: 1.4;
padding: 0 8px;
`;
// The named workflows render the authored default graph; a page may
// carry its own nodes/edges (the type allows it, as on the old site).
export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
const nodes = page.nodes ?? WORKFLOW_GRAPH.nodes;
const edges = page.edges ?? (page.nodes ? [] : WORKFLOW_GRAPH.edges);
const branchLabels =
page.branchLabels ?? (page.nodes ? [] : WORKFLOW_GRAPH.branchLabels);
return (
<PageShell>
<CanvasViewportShell>
<ActiveBadge>
<ActiveBadgeLabel>Active</ActiveBadgeLabel>
</ActiveBadge>
<CanvasViewport
aria-label={`Interactive preview of the ${page.header.title.toLowerCase()} workflow`}
>
<Canvas
$height={WORKFLOW_THEME.canvasHeightPx}
$width={WORKFLOW_THEME.canvasWidthPx}
>
<CanvasContent
$height={WORKFLOW_THEME.canvasHeightPx}
$width={WORKFLOW_THEME.canvasWidthPx}
data-workflow-canvas
>
{page.generating ? null : (
<WorkflowEdges
canvasHeight={WORKFLOW_THEME.canvasHeightPx}
canvasWidth={WORKFLOW_THEME.canvasWidthPx}
edges={edges}
nodes={nodes}
/>
)}
{nodes.map((node, index) => (
<WorkflowNode
generating={page.generating}
index={index}
key={node.id}
node={node}
/>
))}
{page.generating
? null
: branchLabels.map((label) => (
<WorkflowBranchLabel
key={`${label.text}-${label.x}`}
text={label.text}
x={label.x}
y={label.y}
/>
))}
</CanvasContent>
</Canvas>
</CanvasViewport>
</CanvasViewportShell>
</PageShell>
);
}

View File

@@ -0,0 +1,131 @@
// The default node graph (the "Create company when adding a new person"
// workflow), extracted verbatim from the old data.
import { type WorkflowEdgeDef, type WorkflowNodeDef } from '../../types';
export const WORKFLOW_GRAPH = {
nodes: [
{
id: 'trigger',
x: 370,
y: 80,
width: 238,
label: 'Trigger',
title: 'Record is created or updated',
iconName: 'plug',
iconColor: 'trigger',
},
{
id: 'is-personal-email',
x: 620,
y: 210,
width: 220,
label: 'Action',
title: 'Is this a personal email?',
iconName: 'code',
iconColor: 'action',
},
{
id: 'if-business-email',
x: 640,
y: 340,
width: 180,
label: 'Action',
title: 'If business email',
iconName: 'filter',
iconColor: 'fallback',
},
{
id: 'extract-domain',
x: 620,
y: 470,
width: 220,
label: 'Action',
title: 'Extract domain from email',
iconName: 'code',
iconColor: 'action',
},
{
id: 'search-company',
x: 640,
y: 600,
width: 180,
label: 'Action',
title: 'Search Company',
iconName: 'search',
iconColor: 'fallback',
},
{
id: 'find-exact-match',
x: 610,
y: 730,
width: 240,
label: 'Action',
title: 'Find exact company match',
iconName: 'code',
iconColor: 'action',
},
{
id: 'company-already-exists',
x: 600,
y: 860,
width: 260,
label: 'Action',
title: 'If a company already exists',
iconName: 'sitemap',
iconColor: 'fallback',
},
{
id: 'attach-existing-company',
x: 370,
y: 990,
width: 240,
label: 'Action',
title: 'Attach person to existing company',
iconName: 'repeat',
iconColor: 'fallback',
},
{
id: 'create-company',
x: 840,
y: 990,
width: 220,
label: 'Action',
title: 'Create a new company',
iconName: 'plus',
iconColor: 'fallback',
},
{
id: 'attach-created-company',
x: 850,
y: 1120,
width: 240,
label: 'Action',
title: 'Attach person to this company',
iconName: 'repeat',
iconColor: 'fallback',
},
] satisfies WorkflowNodeDef[],
edges: [
{ from: 'trigger', to: 'is-personal-email', type: 'curve' },
{ from: 'is-personal-email', to: 'if-business-email', type: 'vertical' },
{ from: 'if-business-email', to: 'extract-domain', type: 'vertical' },
{ from: 'extract-domain', to: 'search-company', type: 'vertical' },
{ from: 'search-company', to: 'find-exact-match', type: 'vertical' },
{
from: 'find-exact-match',
to: 'company-already-exists',
type: 'vertical',
},
{
from: 'company-already-exists',
to: 'attach-existing-company',
type: 'branch',
},
{ from: 'company-already-exists', to: 'create-company', type: 'branch' },
{ from: 'create-company', to: 'attach-created-company', type: 'vertical' },
] satisfies WorkflowEdgeDef[],
branchLabels: [
{ x: 566, y: 944, text: 'if' },
{ x: 820, y: 944, text: 'else' },
],
};

View File

@@ -0,0 +1,38 @@
import {
getWorkflowEdgePath,
type PositionedWorkflowNode,
} from './workflow-geometry';
const nodes: PositionedWorkflowNode[] = [
{ id: 'first', width: 100, x: 10, y: 20 },
{ id: 'second', width: 80, x: 120, y: 140 },
];
describe('workflow-geometry', () => {
it('should throw for unknown workflow nodes', () => {
expect(() =>
getWorkflowEdgePath({
edge: { from: 'first', to: 'missing', type: 'vertical' },
nodes,
}),
).toThrow('Unknown workflow node: missing');
});
it('should create vertical edge paths from bottom-center to top-center', () => {
expect(
getWorkflowEdgePath({
edge: { from: 'first', to: 'second', type: 'vertical' },
nodes,
}),
).toBe('M60 69 L160 140');
});
it('should create curved edge paths for non-vertical edges', () => {
expect(
getWorkflowEdgePath({
edge: { from: 'first', to: 'second', type: 'curve' },
nodes,
}),
).toBe('M60 69 C60 97 160 112 160 140');
});
});

View File

@@ -0,0 +1,124 @@
import { WORKFLOW_THEME } from './workflow-theme';
import { type WorkflowEdgeDef } from '../../types';
// Pure path math for the node graph's edges, ported verbatim. Ports sit
// at the node's top/bottom center (+1px under the border) and right
// center; every corner turns through an 8px arc.
type Point = { x: number; y: number };
export type PositionedWorkflowNode = {
id: string;
x: number;
y: number;
width: number;
};
const CORNER_RADIUS = 8;
const NODE_HEIGHT = WORKFLOW_THEME.nodeHeightPx;
function getNodeById(
nodes: ReadonlyArray<PositionedWorkflowNode>,
nodeId: string,
): PositionedWorkflowNode {
const node = nodes.find((workflowNode) => workflowNode.id === nodeId);
if (!node) {
throw new Error(`Unknown workflow node: ${nodeId}`);
}
return node;
}
function getNodeTopCenter(node: PositionedWorkflowNode): Point {
return { x: node.x + node.width / 2, y: node.y };
}
function getNodeBottomCenter(node: PositionedWorkflowNode): Point {
return { x: node.x + node.width / 2, y: node.y + NODE_HEIGHT + 1 };
}
function getNodeRightCenter(node: PositionedWorkflowNode): Point {
return { x: node.x + node.width, y: node.y + NODE_HEIGHT / 2 };
}
function getRightDownPath(
fromNode: PositionedWorkflowNode,
toNode: PositionedWorkflowNode,
): string {
const start = getNodeRightCenter(fromNode);
const end = getNodeTopCenter(toNode);
const r = CORNER_RADIUS;
return [
`M${start.x} ${start.y}`,
`L${end.x - r} ${start.y}`,
`A${r} ${r} 0 0 1 ${end.x} ${start.y + r}`,
`L${end.x} ${end.y}`,
].join(' ');
}
function getLoopBackPath(
fromNode: PositionedWorkflowNode,
toNode: PositionedWorkflowNode,
): string {
const r = CORNER_RADIUS;
const padRight = 40;
const padBottom = 180;
const start = getNodeBottomCenter(fromNode);
const endX = toNode.x + toNode.width / 2;
const endY = toNode.y + NODE_HEIGHT + 50;
const rightX = fromNode.x + fromNode.width + padRight;
const bottomY = start.y + padBottom;
return [
`M${start.x} ${start.y}`,
`L${start.x} ${bottomY - r}`,
`A${r} ${r} 0 0 0 ${start.x + r} ${bottomY}`,
`L${rightX - r} ${bottomY}`,
`A${r} ${r} 0 0 0 ${rightX} ${bottomY - r}`,
`L${rightX} ${endY + r}`,
`A${r} ${r} 0 0 0 ${rightX - r} ${endY}`,
`L${endX} ${endY}`,
].join(' ');
}
function getSmoothStepPath(start: Point, end: Point): string {
const r = CORNER_RADIUS;
const midY = start.y + (end.y - start.y) * 0.4;
if (Math.abs(start.x - end.x) < 2) {
return `M${start.x} ${start.y} L${end.x} ${end.y}`;
}
const goingRight = end.x > start.x;
return [
`M${start.x} ${start.y}`,
`L${start.x} ${midY - r}`,
`A${r} ${r} 0 0 ${goingRight ? 0 : 1} ${start.x + (goingRight ? r : -r)} ${midY}`,
`L${end.x - (goingRight ? r : -r)} ${midY}`,
`A${r} ${r} 0 0 ${goingRight ? 1 : 0} ${end.x} ${midY + r}`,
`L${end.x} ${end.y}`,
].join(' ');
}
export function getWorkflowEdgePath({
edge,
nodes,
}: {
edge: WorkflowEdgeDef;
nodes: ReadonlyArray<PositionedWorkflowNode>;
}): string {
const fromNode = getNodeById(nodes, edge.from);
const toNode = getNodeById(nodes, edge.to);
if (edge.type === 'loopRight') {
return getRightDownPath(fromNode, toNode);
}
if (edge.type === 'loopBack') {
return getLoopBackPath(fromNode, toNode);
}
const start = getNodeBottomCenter(fromNode);
const end = getNodeTopCenter(toNode);
if (edge.type === 'vertical') {
return `M${start.x} ${start.y} L${end.x} ${end.y}`;
}
if (edge.type === 'smoothStep') {
return getSmoothStepPath(start, end);
}
const controlStartY = start.y + 28;
const controlEndY = end.y - 28;
return `M${start.x} ${start.y} C${start.x} ${controlStartY} ${end.x} ${controlEndY} ${end.x} ${end.y}`;
}

View File

@@ -0,0 +1,28 @@
import { THEME_LIGHT } from 'twenty-ui/theme';
import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones';
// The workflow canvas vocabulary: authored inks from the tones home,
// chrome from the product-derived theme.
export const WORKFLOW_THEME = {
canvasWidthPx: 1480,
canvasHeightPx: 1260,
nodeHeightPx: 48,
canvasTopOffsetPx: 16,
colors: {
activeBadgeBackground:
APP_PREVIEW_TONES.workflowCanvas.activeBadgeBackground,
activeBadgeText: APP_PREVIEW_TONES.workflowCanvas.activeBadgeText,
arrowStroke: APP_PREVIEW_TONES.workflowCanvas.arrowStroke,
canvasBackground: THEME_LIGHT.background.primary,
canvasDot: THEME_LIGHT.border.color.medium,
nodeActionIcon: APP_PREVIEW_TONES.workflowCanvas.actionIcon,
nodeTriggerIcon: APP_PREVIEW_TONES.workflowCanvas.triggerIcon,
nodeIconFallback: THEME_LIGHT.font.color.secondary,
nodeBorder: THEME_LIGHT.border.color.strong,
nodeSurface: THEME_LIGHT.background.secondary,
nodeIconSurface: THEME_LIGHT.background.transparent.light,
textPrimary: THEME_LIGHT.font.color.primary,
textLight: THEME_LIGHT.font.color.light,
textTertiary: THEME_LIGHT.font.color.tertiary,
},
};