From d65d9195cee090323bdcc29e673eea23d1469765 Mon Sep 17 00:00:00 2001 From: justinsunyt <33591641+justinsunyt@users.noreply.github.com> Date: Fri, 19 Jun 2026 05:15:17 +0000 Subject: [PATCH 1/2] feat(dashboard): add first-class section header tiles with full-width layout and analytics Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../components/Cards/TextCard/TextCard.scss | 32 +++++ .../Cards/TextCard/TextCard.test.tsx | 14 +++ .../components/Cards/TextCard/TextCard.tsx | 20 ++- .../Cards/TextCard/TextCardModal.tsx | 115 ++++++++++++++---- .../Cards/TextCard/textCardModalLogic.test.ts | 57 +++++++++ .../Cards/TextCard/textCardModalLogic.ts | 74 +++++++++-- .../Cards/TextCard/textCardUtils.ts | 56 +++++++++ .../dashboard/DashboardHeaderActions.tsx | 5 + .../src/scenes/dashboard/DashboardItems.tsx | 51 +++++--- .../src/scenes/dashboard/DashboardModals.tsx | 8 ++ .../EmptyDashboardComponent.test.tsx | 3 +- .../dashboard/EmptyDashboardComponent.tsx | 17 ++- .../src/scenes/dashboard/tileLayouts.test.ts | 16 +++ frontend/src/scenes/dashboard/tileLayouts.ts | 6 +- 14 files changed, 410 insertions(+), 64 deletions(-) create mode 100644 frontend/src/lib/components/Cards/TextCard/textCardUtils.ts diff --git a/frontend/src/lib/components/Cards/TextCard/TextCard.scss b/frontend/src/lib/components/Cards/TextCard/TextCard.scss index aa137025c5ec..73b720c1a31e 100644 --- a/frontend/src/lib/components/Cards/TextCard/TextCard.scss +++ b/frontend/src/lib/components/Cards/TextCard/TextCard.scss @@ -18,3 +18,35 @@ max-width: 100%; } } + +.TextCard--section-header { + justify-content: center; + border-radius: 0; + border-bottom: 1px solid var(--color-border-primary); + + .TextCard__body { + display: flex; + align-items: center; + overflow: hidden; + } + + .RichMarkdownEditor, + .LemonMarkdown { + overflow: hidden; + } + + .RichMarkdownEditor__content { + h1, + h2, + h3, + p { + margin: 0; + } + + h1 { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.75rem; + } + } +} diff --git a/frontend/src/lib/components/Cards/TextCard/TextCard.test.tsx b/frontend/src/lib/components/Cards/TextCard/TextCard.test.tsx index 9eeabd6aceb6..84fc6d9649ef 100644 --- a/frontend/src/lib/components/Cards/TextCard/TextCard.test.tsx +++ b/frontend/src/lib/components/Cards/TextCard/TextCard.test.tsx @@ -54,6 +54,20 @@ describe('TextCard', () => { expect(onEnterEditModeFromEdge).toHaveBeenCalledTimes(1) }) + it('renders section headers as polished full-width rows', () => { + const { container } = render( + + ) + + expect(container.querySelector('[data-attr="section-header-card"]')).toHaveClass('TextCard--section-header') + }) + describe('TextContent', () => { it('calls closeDetails when clicked', () => { const closeDetails = jest.fn() diff --git a/frontend/src/lib/components/Cards/TextCard/TextCard.tsx b/frontend/src/lib/components/Cards/TextCard/TextCard.tsx index d36a5beaa758..295144021580 100644 --- a/frontend/src/lib/components/Cards/TextCard/TextCard.tsx +++ b/frontend/src/lib/components/Cards/TextCard/TextCard.tsx @@ -16,6 +16,7 @@ import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown' import { DashboardPlacement, DashboardTile, QueryBasedInsightModel } from '~/types' import { markdownToTextCardDoc, TEXT_CARD_MARKDOWN_READONLY_EXTENSIONS } from './textCardMarkdown' +import { isDashboardSectionHeaderTextTile } from './textCardUtils' interface TextCardProps extends React.HTMLAttributes, Resizeable { textTile: DashboardTile @@ -95,6 +96,7 @@ function TextCardInternal( const shouldHideMoreButton = placement === DashboardPlacement.Public || showEditingControls === false const isTransparent = textTile.transparent_background + const isSectionHeader = isDashboardSectionHeaderTextTile(textTile) return ( {moreButtonOverlay && !shouldHideMoreButton && ( - + )} @@ -118,7 +121,18 @@ function TextCardInternal( className={clsx('TextCard__body w-full', onDragHandleMouseDown && 'cursor-grab')} onMouseDown={onDragHandleMouseDown} > - + {canEnterEditModeFromEdge && !showResizeHandles && onEnterEditModeFromEdge && ( diff --git a/frontend/src/lib/components/Cards/TextCard/TextCardModal.tsx b/frontend/src/lib/components/Cards/TextCard/TextCardModal.tsx index a667f83b511e..8253070a0038 100644 --- a/frontend/src/lib/components/Cards/TextCard/TextCardModal.tsx +++ b/frontend/src/lib/components/Cards/TextCard/TextCardModal.tsx @@ -5,34 +5,65 @@ import { useCallback, useState } from 'react' import { isTextCardMarkdownRoundTripSafe } from 'lib/components/Cards/TextCard/textCardMarkdown' import { TextCardModalBodyField } from 'lib/components/Cards/TextCard/TextCardModalBodyField' import { textCardModalLogic } from 'lib/components/Cards/TextCard/textCardModalLogic' +import { + DashboardTextTileKind, + DEFAULT_DASHBOARD_SECTION_HEADER_TITLE, + getDashboardSectionHeaderTitle, + getDashboardTextTileKind, +} from 'lib/components/Cards/TextCard/textCardUtils' import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { LemonInput } from 'lib/lemon-ui/LemonInput' import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch' import { DialogClose, DialogPrimitive, DialogPrimitiveTitle } from 'lib/ui/DialogPrimitive/DialogPrimitive' import { cn } from 'lib/utils/css-classes' -import { DashboardType, QueryBasedInsightModel } from '~/types' +import { DashboardTile, DashboardType, QueryBasedInsightModel } from '~/types' + +function getInitialBody( + dashboard: DashboardType, + textTileId: number | 'new', + textTileKind: DashboardTextTileKind +): string { + if (textTileId === 'new') { + return textTileKind === 'section' ? DEFAULT_DASHBOARD_SECTION_HEADER_TITLE : '' + } + + const body = dashboard.tiles?.find((tile) => tile.id === textTileId)?.text?.body || '' + return textTileKind === 'section' ? (getDashboardSectionHeaderTitle(body) ?? body) : body +} export function TextCardModal({ isOpen, onClose, dashboard, textTileId, + textTileKind = 'text', + defaultLayouts, }: { isOpen: boolean onClose: () => void dashboard: DashboardType textTileId: number | 'new' | null + textTileKind?: DashboardTextTileKind + defaultLayouts?: DashboardTile['layouts'] }): JSX.Element { const resolvedTileId = textTileId ?? 'new' - const modalLogicProps = { dashboard, textTileId: resolvedTileId, onClose } + const existingTile = resolvedTileId !== 'new' ? dashboard.tiles?.find((tile) => tile.id === resolvedTileId) : null + const resolvedTextTileKind = getDashboardTextTileKind(existingTile, textTileKind) + const modalLogicProps = { + dashboard, + textTileId: resolvedTileId, + onClose, + textTileKind: resolvedTextTileKind, + defaultLayouts, + } const modalLogic = textCardModalLogic(modalLogicProps) // Form `body` + validation drive updates while typing; splitting useValues does not reduce rerenders. const { isTextTileSubmitting, textTileValidationErrors, textTile } = useValues(modalLogic) const { resetTextTile } = useActions(modalLogic) - const [initialBody] = useState(() => - resolvedTileId !== 'new' ? dashboard.tiles?.find((tile) => tile.id === resolvedTileId)?.text?.body || '' : '' - ) - const shouldUseLegacyMarkdownEditor = !isTextCardMarkdownRoundTripSafe(initialBody) + const [initialBody] = useState(() => getInitialBody(dashboard, resolvedTileId, resolvedTextTileKind)) + const shouldUseLegacyMarkdownEditor = + resolvedTextTileKind === 'text' && !isTextCardMarkdownRoundTripSafe(initialBody) const hasUnsavedInput = (textTile?.body || '') !== initialBody const handleClose = useCallback((): void => { @@ -55,7 +86,13 @@ export function TextCardModal({ > - {resolvedTileId === 'new' ? 'Add text card' : 'Edit text card'} + {resolvedTextTileKind === 'section' + ? resolvedTileId === 'new' + ? 'Add section header' + : 'Edit section header' + : resolvedTileId === 'new' + ? 'Add text card' + : 'Edit text card'} @@ -70,24 +107,42 @@ export function TextCardModal({ > - {({ value, onChange }) => ( - - )} - - - {({ value, onChange }) => ( - - )} + {({ value, onChange }) => + resolvedTextTileKind === 'section' ? ( + + + + Section headers are full-width text tiles for grouping dashboard + content. Drag them in edit mode to rearrange your dashboard. + + + ) : ( + + ) + } + {resolvedTextTileKind === 'text' && ( + + {({ value, onChange }) => ( + + )} + + )} @@ -105,9 +160,17 @@ export function TextCardModal({ form="text-tile-form" htmlType="submit" type="primary" - data-attr={resolvedTileId === 'new' ? 'save-new-text-tile' : 'edit-text-tile-text'} + data-attr={ + resolvedTextTileKind === 'section' + ? resolvedTileId === 'new' + ? 'save-new-section-header' + : 'edit-section-header' + : resolvedTileId === 'new' + ? 'save-new-text-tile' + : 'edit-text-tile-text' + } > - Save + {resolvedTextTileKind === 'section' && resolvedTileId === 'new' ? 'Add section' : 'Save'} diff --git a/frontend/src/lib/components/Cards/TextCard/textCardModalLogic.test.ts b/frontend/src/lib/components/Cards/TextCard/textCardModalLogic.test.ts index 3fcc133b740b..1a38e6e0692d 100644 --- a/frontend/src/lib/components/Cards/TextCard/textCardModalLogic.test.ts +++ b/frontend/src/lib/components/Cards/TextCard/textCardModalLogic.test.ts @@ -1,7 +1,10 @@ import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' import { lemonToast } from '@posthog/lemon-ui' +import { useMocks } from '~/mocks/jest' +import { dashboardsModel } from '~/models/dashboardsModel' import { initKeaTests } from '~/test/init' import { AccessControlLevel, DashboardType, QueryBasedInsightModel } from '~/types' @@ -38,7 +41,9 @@ const makeDashboard = (body: string = 'existing text'): DashboardType { beforeEach(() => { initKeaTests() + dashboardsModel.mount() jest.spyOn(lemonToast, 'error').mockImplementation(jest.fn()) + jest.spyOn(posthog, 'capture').mockImplementation(jest.fn()) }) afterEach(() => { @@ -116,4 +121,56 @@ describe('textCardModalLogic', () => { expect(lemonToast.error).toHaveBeenCalledWith('Could not save text: Network error') }) + + it('creates section headers as full-width transparent text tiles', async () => { + let dashboardPatchPayload: Partial> | null = null + useMocks({ + patch: { + '/api/environments/:team_id/dashboards/:id/': async (req) => { + dashboardPatchPayload = await req.json() + return [200, { ...makeDashboard('valid'), ...dashboardPatchPayload }] + }, + }, + }) + const logic = textCardModalLogic({ + dashboard: makeDashboard('valid'), + textTileId: 'new', + textTileKind: 'section', + defaultLayouts: { sm: { x: 0, y: 4, w: 12, h: 1 }, xs: { x: 0, y: 2, w: 1, h: 1 } }, + onClose: jest.fn(), + }) + logic.mount() + + await expectLogic(logic, () => { + logic.actions.setTextTileValue('body', 'Activation') + logic.actions.submitTextTile() + }).toFinishAllListeners() + + expect(dashboardPatchPayload).toEqual({ + tiles: [ + { + text: { body: '# Activation' }, + transparent_background: true, + layouts: { sm: { x: 0, y: 4, w: 12, h: 1 }, xs: { x: 0, y: 2, w: 1, h: 1 } }, + }, + ], + }) + }) + + it('records when a section header is added', () => { + const logic = textCardModalLogic({ + dashboard: makeDashboard('valid'), + textTileId: 'new', + textTileKind: 'section', + onClose: jest.fn(), + }) + logic.mount() + + logic.actions.submitTextTileSuccess({ body: 'Activation', transparent_background: true }) + + expect(posthog.capture).toHaveBeenCalledWith('dashboard section header added', { + dashboard_id: 123, + title_length: 10, + }) + }) }) diff --git a/frontend/src/lib/components/Cards/TextCard/textCardModalLogic.ts b/frontend/src/lib/components/Cards/TextCard/textCardModalLogic.ts index 6d5dc5dd754d..88f55ae379c4 100644 --- a/frontend/src/lib/components/Cards/TextCard/textCardModalLogic.ts +++ b/frontend/src/lib/components/Cards/TextCard/textCardModalLogic.ts @@ -8,6 +8,13 @@ import { dashboardsModel } from '~/models/dashboardsModel' import { DashboardTile, DashboardType, QueryBasedInsightModel } from '~/types' import type { textCardModalLogicType } from './textCardModalLogicType' +import { + DashboardTextTileKind, + DEFAULT_DASHBOARD_SECTION_HEADER_TITLE, + getDashboardSectionHeaderMarkdown, + getDashboardSectionHeaderTitle, + getDashboardTextTileKind, +} from './textCardUtils' export interface TextTileForm { body: string @@ -18,22 +25,34 @@ export interface TextCardModalProps { dashboard: DashboardType textTileId: number | 'new' onClose: () => void + textTileKind?: DashboardTextTileKind + defaultLayouts?: DashboardTile['layouts'] } const MAX_TEXT_CARD_BODY_LENGTH = 4000 -const getExistingTextTile = (dashboard: DashboardType, textTileId: number): TextTileForm => { +const resolveTextTileKind = (props: TextCardModalProps): DashboardTextTileKind => { + const tile = props.textTileId !== 'new' ? props.dashboard.tiles?.find((tt) => tt.id === props.textTileId) : null + return getDashboardTextTileKind(tile, props.textTileKind ?? 'text') +} + +const getExistingTextTile = ( + dashboard: DashboardType, + textTileId: number, + textTileKind: DashboardTextTileKind +): TextTileForm => { const tile = dashboard.tiles?.find((tt) => tt.id === textTileId) + const body = tile?.text?.body || '' return { - body: tile?.text?.body || '', - transparent_background: tile?.transparent_background ?? false, + body: textTileKind === 'section' ? (getDashboardSectionHeaderTitle(body) ?? body) : body, + transparent_background: textTileKind === 'section' ? true : (tile?.transparent_background ?? false), } } export const textCardModalLogic = kea([ path(['scenes', 'dashboard', 'dashboardTextTileModal', 'logic']), props({} as TextCardModalProps), - key((props) => `textCardModalLogic-${props.dashboard.id}-${props.textTileId}`), + key((props) => `textCardModalLogic-${props.dashboard.id}-${props.textTileId}-${resolveTextTileKind(props)}`), connect(() => ({ actions: [dashboardsModel, ['updateDashboard']] })), listeners(({ props, actions, values }) => ({ submitTextTileFailure: (error) => { @@ -68,6 +87,7 @@ export const textCardModalLogic = kea([ } }, submitTextTileSuccess: ({ textTile }: { textTile: TextTileForm }) => { + const textTileKind = resolveTextTileKind(props) actions.resetTextTile() props?.onClose?.() @@ -76,24 +96,49 @@ export const textCardModalLogic = kea([ text_tile_id: props.textTileId === 'new' ? null : props.textTileId, is_new: props.textTileId === 'new', body_length: textTile.body.length, + text_tile_kind: textTileKind, }) + + if (textTileKind === 'section' && props.textTileId === 'new') { + posthog.capture('dashboard section header added', { + dashboard_id: props.dashboard.id, + title_length: textTile.body.trim().length, + }) + } }, })), forms(({ props, actions }) => ({ textTile: { - defaults: (props.textTileId && props.textTileId !== 'new' - ? getExistingTextTile(props.dashboard, props.textTileId) - : { body: '', transparent_background: false }) as TextTileForm, + defaults: (() => { + const textTileKind = resolveTextTileKind(props) + return ( + props.textTileId && props.textTileId !== 'new' + ? getExistingTextTile(props.dashboard, props.textTileId, textTileKind) + : { + body: textTileKind === 'section' ? DEFAULT_DASHBOARD_SECTION_HEADER_TITLE : '', + transparent_background: textTileKind === 'section', + } + ) as TextTileForm + })(), errors: ({ body }) => { + const textTileKind = resolveTextTileKind(props) + const storedBodyLength = + textTileKind === 'section' ? getDashboardSectionHeaderMarkdown(body).length : body.length return { body: !body.trim() - ? 'This card would be empty! Type something first' - : body.length > MAX_TEXT_CARD_BODY_LENGTH + ? textTileKind === 'section' + ? 'Add a section title' + : 'This card would be empty! Type something first' + : storedBodyLength > MAX_TEXT_CARD_BODY_LENGTH ? `Text is too long (${MAX_TEXT_CARD_BODY_LENGTH} characters max)` : null, } }, submit: (formValues) => { + const textTileKind = resolveTextTileKind(props) + const body = + textTileKind === 'section' ? getDashboardSectionHeaderMarkdown(formValues.body) : formValues.body + const transparentBackground = textTileKind === 'section' ? true : formValues.transparent_background // only id and body, layout and color could be out-of-date const textTiles = (props.dashboard.tiles || []).map((t) => ({ id: t.id, @@ -106,16 +151,19 @@ export const textCardModalLogic = kea([ id: props.dashboard.id, tiles: [ { - text: { body: formValues.body }, - transparent_background: formValues.transparent_background, + text: { body }, + transparent_background: transparentBackground, + ...(textTileKind === 'section' && props.defaultLayouts + ? { layouts: props.defaultLayouts } + : {}), }, ], }) } else { const updatedTiles = [...textTiles].reduce((acc, tile) => { if (tile.id === props.textTileId && tile.text) { - tile.text.body = formValues.body - ;(tile as Partial).transparent_background = formValues.transparent_background + tile.text.body = body + ;(tile as Partial).transparent_background = transparentBackground acc.push(tile) } return acc diff --git a/frontend/src/lib/components/Cards/TextCard/textCardUtils.ts b/frontend/src/lib/components/Cards/TextCard/textCardUtils.ts new file mode 100644 index 000000000000..aa22a24c2349 --- /dev/null +++ b/frontend/src/lib/components/Cards/TextCard/textCardUtils.ts @@ -0,0 +1,56 @@ +import type { Layout, ResponsiveLayouts } from 'react-grid-layout' + +import type { DashboardTile, QueryBasedInsightModel } from '~/types' + +export type DashboardTextTileKind = 'text' | 'section' + +export const DEFAULT_DASHBOARD_SECTION_HEADER_TITLE = 'New section' + +const DASHBOARD_GRID_COLUMNS = 12 +const DASHBOARD_SECTION_HEADER_HEIGHT = 1 +const DASHBOARD_SECTION_HEADER_REGEX = /^#\s+([^\n]+)$/ + +export function getDashboardSectionHeaderMarkdown(title: string): string { + return `# ${title.trim() || DEFAULT_DASHBOARD_SECTION_HEADER_TITLE}` +} + +export function getDashboardSectionHeaderTitle(body: string | null | undefined): string | null { + const match = body?.trim().match(DASHBOARD_SECTION_HEADER_REGEX) + return match ? match[1].trim() : null +} + +export function isDashboardSectionHeaderTextTile( + tile: Pick, 'text' | 'transparent_background'> | null | undefined +): boolean { + return tile?.transparent_background === true && getDashboardSectionHeaderTitle(tile.text?.body) !== null +} + +export function getDashboardTextTileKind( + tile: Pick, 'text' | 'transparent_background'> | null | undefined, + fallback: DashboardTextTileKind = 'text' +): DashboardTextTileKind { + return isDashboardSectionHeaderTextTile(tile) ? 'section' : fallback +} + +function getNextDashboardLayoutY(layouts: Layout | undefined): number { + return (layouts ?? []).reduce((maxY, layout) => Math.max(maxY, (layout.y ?? 0) + (layout.h ?? 0)), 0) +} + +export function getDashboardSectionHeaderLayouts( + layouts: ResponsiveLayouts | null | undefined +): DashboardTile['layouts'] { + return { + sm: { + x: 0, + y: getNextDashboardLayoutY(layouts?.sm), + w: DASHBOARD_GRID_COLUMNS, + h: DASHBOARD_SECTION_HEADER_HEIGHT, + }, + xs: { + x: 0, + y: getNextDashboardLayoutY(layouts?.xs ?? layouts?.sm), + w: 1, + h: DASHBOARD_SECTION_HEADER_HEIGHT, + }, + } +} diff --git a/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx b/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx index 2ca397a2fa7e..fcaaabbbbdfb 100644 --- a/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx +++ b/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx @@ -62,6 +62,11 @@ export function DashboardAddTileButton(): JSX.Element | null { onClick: showAddInsightToDashboardModal, 'data-attr': 'dashboard-add-insight', }, + { + label: 'Section header', + onClick: () => push(urls.dashboardTextTile(dashboard.id, 'new'), { type: 'section' }), + 'data-attr': 'dashboard-add-section-header', + }, { label: 'Text card', onClick: () => push(urls.dashboardTextTile(dashboard.id, 'new')), diff --git a/frontend/src/scenes/dashboard/DashboardItems.tsx b/frontend/src/scenes/dashboard/DashboardItems.tsx index f0f81a3387b1..5ef2bb5e7365 100644 --- a/frontend/src/scenes/dashboard/DashboardItems.tsx +++ b/frontend/src/scenes/dashboard/DashboardItems.tsx @@ -3,8 +3,9 @@ import './DashboardItems.scss' import clsx from 'clsx' import { useActions, useAsyncActions, useValues } from 'kea' import { router } from 'kea-router' +import posthog from 'posthog-js' import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Layout, Responsive as ReactGridLayout, useContainerWidth } from 'react-grid-layout' +import { Layout, LayoutItem, Responsive as ReactGridLayout, useContainerWidth } from 'react-grid-layout' import { GridBackground } from 'react-grid-layout/extras' import { DashboardWidgetItem } from '@posthog/products-dashboards/frontend/components/DashboardWidgetItem/DashboardWidgetItem' @@ -12,6 +13,7 @@ import { getDashboardWidgetFetchDisplayError } from '@posthog/products-dashboard import { InsightCard } from 'lib/components/Cards/InsightCard' import { EditModeEdge } from 'lib/components/Cards/InsightCard/EditModeEdgeOverlay' +import { isDashboardSectionHeaderTextTile } from 'lib/components/Cards/TextCard/textCardUtils' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { DashboardEventSource, eventUsageLogic } from 'lib/utils/eventUsageLogic' import { dashboardLogic } from 'scenes/dashboard/dashboardLogic' @@ -339,23 +341,36 @@ export function DashboardItems(): JSX.Element { [] ) - const handleDragStop = useCallback(() => { - if (scrollAnimationRef.current) { - cancelAnimationFrame(scrollAnimationRef.current) - scrollAnimationRef.current = null - } - scrollContainerRef.current = null - scrollContainerRectRef.current = null - if (dragEndTimeout.current) { - window.clearTimeout(dragEndTimeout.current) - } - dragEndTimeout.current = window.setTimeout(() => { - isDragging.current = false - }, 250) - if (dashboard?.id) { - reportDashboardTileRepositioned(dashboard.id, 'moved', effectiveZoom) - } - }, [dashboard?.id, reportDashboardTileRepositioned, effectiveZoom]) + const handleDragStop = useCallback( + (_layout: Layout, _oldItem: LayoutItem | null, newItem: LayoutItem | null) => { + if (scrollAnimationRef.current) { + cancelAnimationFrame(scrollAnimationRef.current) + scrollAnimationRef.current = null + } + scrollContainerRef.current = null + scrollContainerRectRef.current = null + if (dragEndTimeout.current) { + window.clearTimeout(dragEndTimeout.current) + } + dragEndTimeout.current = window.setTimeout(() => { + isDragging.current = false + }, 250) + if (dashboard?.id) { + reportDashboardTileRepositioned(dashboard.id, 'moved', effectiveZoom) + + const movedTileId = Number(newItem?.i) + const movedTile = Number.isFinite(movedTileId) ? tiles?.find((tile) => tile.id === movedTileId) : null + if (movedTile && isDashboardSectionHeaderTextTile(movedTile)) { + posthog.capture('dashboard section header rearranged', { + dashboard_id: dashboard.id, + tile_id: movedTile.id, + layout_zoom: effectiveZoom, + }) + } + } + }, + [dashboard?.id, reportDashboardTileRepositioned, effectiveZoom, tiles] + ) return ( }> diff --git a/frontend/src/scenes/dashboard/DashboardModals.tsx b/frontend/src/scenes/dashboard/DashboardModals.tsx index a9865cbeaffc..14228d94b40f 100644 --- a/frontend/src/scenes/dashboard/DashboardModals.tsx +++ b/frontend/src/scenes/dashboard/DashboardModals.tsx @@ -5,6 +5,7 @@ import { AddWidgetModal } from '@posthog/products-dashboards/frontend/widgets/Ad import { ButtonTileCardModal } from 'lib/components/Cards/ButtonTileCard/ButtonTileCardModal' import { TextCardModal } from 'lib/components/Cards/TextCard/TextCardModal' +import { DashboardTextTileKind, getDashboardSectionHeaderLayouts } from 'lib/components/Cards/TextCard/textCardUtils' import { SharingModal } from 'lib/components/Sharing/SharingModal' import { SubscriptionsModal } from 'lib/components/Subscriptions/SubscriptionsModal' import { TerraformExportModal } from 'lib/components/TerraformExporter/TerraformExportModal' @@ -34,11 +35,14 @@ export function DashboardModals({ dashboard }: { dashboard: DashboardType @@ -63,6 +67,10 @@ export function DashboardModals({ dashboard }: { dashboard: DashboardType push(urls.dashboard(dashboard.id))} dashboard={dashboard} textTileId={textTileId} + textTileKind={textTileKind} + defaultLayouts={ + textTileKind === 'section' ? getDashboardSectionHeaderLayouts(layouts) : undefined + } /> { logic.unmount() }) - it('shows Add text card in Get started dropdown', async () => { + it('shows Add section header and Add text card in Get started dropdown', async () => { const { logic } = renderEmptyState() await openGetStartedDropdown() + expect(screen.getByText('Add section header')).toBeInTheDocument() expect(screen.getByText('Add text card')).toBeInTheDocument() expect(screen.getByText('Add widget')).toBeInTheDocument() expect(screen.getByText('BETA')).toBeInTheDocument() diff --git a/frontend/src/scenes/dashboard/EmptyDashboardComponent.tsx b/frontend/src/scenes/dashboard/EmptyDashboardComponent.tsx index 262f91c5685b..554582fcd99b 100644 --- a/frontend/src/scenes/dashboard/EmptyDashboardComponent.tsx +++ b/frontend/src/scenes/dashboard/EmptyDashboardComponent.tsx @@ -47,7 +47,7 @@ function DashboardEmptyActions({ dashboardWidgetsEnabled: boolean onAddInsight: () => void onAddWidget: () => void - push: (path: string) => void + push: (path: string, searchParams?: Record) => void onOpenAiWithPrompt: (prompt: string) => void }): JSX.Element { const chipDisabledReason = !canEdit ? DASHBOARD_CANNOT_EDIT_MESSAGE : aiDisabledReason || undefined @@ -66,6 +66,21 @@ function DashboardEmptyActions({ placement: 'bottom-end', overlay: ( <> + + { + push(urls.dashboardTextTile(dashboard.id, 'new'), { type: 'section' }) + }} + data-attr="add-section-header-to-dashboard" + > + Add section header + + } +function sectionHeaderTile(tileId: number = 1): DashboardTile { + return { + id: tileId, + text: { body: '# Activation' }, + transparent_background: true, + layouts: {}, + } as DashboardTile +} + describe('calculating tile layouts', () => { it('minimum width and height are added if missing', () => { const tiles: DashboardTile[] = [ @@ -86,6 +95,13 @@ describe('calculating tile layouts', () => { expect(result.xs?.[0]?.h).toBe(expectedXsH) }) + it('uses full-width one-row defaults for section header text tiles', () => { + const result = calculateLayouts([sectionHeaderTile()]) + + expect(result.sm?.[0]).toEqual({ i: '1', x: 0, y: 0, w: 12, h: 1, minW: 1, minH: 1 }) + expect(result.xs?.[0]).toEqual({ i: '1', x: 0, y: 0, w: 1, h: 1, minW: 1, minH: 1 }) + }) + it('xs follows final sm row-major order when some tiles have no stored sm layout', () => { const tiles: DashboardTile[] = [ textTileWithLayout( diff --git a/frontend/src/scenes/dashboard/tileLayouts.ts b/frontend/src/scenes/dashboard/tileLayouts.ts index bf6671e254b9..7240d816130f 100644 --- a/frontend/src/scenes/dashboard/tileLayouts.ts +++ b/frontend/src/scenes/dashboard/tileLayouts.ts @@ -6,6 +6,7 @@ import { type DashboardWidgetCatalogEntry, } from '@posthog/products-dashboards/frontend/widget_types/catalog' +import { isDashboardSectionHeaderTextTile } from 'lib/components/Cards/TextCard/textCardUtils' import { BREAKPOINT_COLUMN_COUNTS } from 'scenes/dashboard/dashboardUtils' import { getQueryBasedInsightModel } from '~/queries/nodes/InsightViz/utils' @@ -192,8 +193,9 @@ export const calculateLayouts = ( let defaultH = 5 // Content-adjusted constraints (note that widths should be factors of 12) if (tile.text) { - defaultW = 2 - defaultH = 2 + const isSectionHeaderTextTile = isDashboardSectionHeaderTextTile(tile) + defaultW = isSectionHeaderTextTile ? columnCount : 2 + defaultH = isSectionHeaderTextTile ? 1 : 2 } else if (isFunnelsQuery(query)) { defaultW = 4 defaultH = 4 From 19d592e1716fd420f894bc3029e4d6eb36fc7119 Mon Sep 17 00:00:00 2001 From: justinsunyt <33591641+justinsunyt@users.noreply.github.com> Date: Fri, 19 Jun 2026 05:22:43 +0000 Subject: [PATCH 2/2] fix(dashboard): polish section header review issues Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../src/lib/components/Cards/TextCard/TextCardModal.tsx | 7 +++++-- frontend/src/scenes/dashboard/DashboardItems.tsx | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/Cards/TextCard/TextCardModal.tsx b/frontend/src/lib/components/Cards/TextCard/TextCardModal.tsx index 8253070a0038..f37570794b2f 100644 --- a/frontend/src/lib/components/Cards/TextCard/TextCardModal.tsx +++ b/frontend/src/lib/components/Cards/TextCard/TextCardModal.tsx @@ -1,6 +1,6 @@ import { useActions, useValues } from 'kea' import { Field, Form } from 'kea-forms' -import { useCallback, useState } from 'react' +import { useCallback, useMemo } from 'react' import { isTextCardMarkdownRoundTripSafe } from 'lib/components/Cards/TextCard/textCardMarkdown' import { TextCardModalBodyField } from 'lib/components/Cards/TextCard/TextCardModalBodyField' @@ -61,7 +61,10 @@ export function TextCardModal({ // Form `body` + validation drive updates while typing; splitting useValues does not reduce rerenders. const { isTextTileSubmitting, textTileValidationErrors, textTile } = useValues(modalLogic) const { resetTextTile } = useActions(modalLogic) - const [initialBody] = useState(() => getInitialBody(dashboard, resolvedTileId, resolvedTextTileKind)) + const initialBody = useMemo( + () => getInitialBody(dashboard, resolvedTileId, resolvedTextTileKind), + [dashboard, resolvedTileId, resolvedTextTileKind] + ) const shouldUseLegacyMarkdownEditor = resolvedTextTileKind === 'text' && !isTextCardMarkdownRoundTripSafe(initialBody) const hasUnsavedInput = (textTile?.body || '') !== initialBody diff --git a/frontend/src/scenes/dashboard/DashboardItems.tsx b/frontend/src/scenes/dashboard/DashboardItems.tsx index 5ef2bb5e7365..9e8fc2a6dd69 100644 --- a/frontend/src/scenes/dashboard/DashboardItems.tsx +++ b/frontend/src/scenes/dashboard/DashboardItems.tsx @@ -360,7 +360,13 @@ export function DashboardItems(): JSX.Element { const movedTileId = Number(newItem?.i) const movedTile = Number.isFinite(movedTileId) ? tiles?.find((tile) => tile.id === movedTileId) : null - if (movedTile && isDashboardSectionHeaderTextTile(movedTile)) { + if ( + movedTile && + isDashboardSectionHeaderTextTile(movedTile) && + _oldItem && + newItem && + (_oldItem.x !== newItem.x || _oldItem.y !== newItem.y) + ) { posthog.capture('dashboard section header rearranged', { dashboard_id: dashboard.id, tile_id: movedTile.id,
+ Section headers are full-width text tiles for grouping dashboard + content. Drag them in edit mode to rearrange your dashboard. +