From dc8403628c1aa0bd41b668fbb3a9689e7fd2c945 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:41:29 +0000 Subject: [PATCH] feat(dashboards): add section headers --- .../Cards/TextCard/sectionHeader.test.ts | 25 +++ .../Cards/TextCard/sectionHeader.ts | 53 +++++ .../dashboard/DashboardHeaderActions.tsx | 7 +- .../src/scenes/dashboard/DashboardItems.tsx | 33 ++++ .../src/scenes/dashboard/DashboardModals.tsx | 29 ++- .../scenes/dashboard/SectionHeaderModal.tsx | 85 ++++++++ .../items/DashboardSectionHeaderItem.test.tsx | 57 ++++++ .../items/DashboardSectionHeaderItem.tsx | 132 +++++++++++++ .../dashboard/sectionHeaderModalLogic.test.ts | 115 +++++++++++ .../dashboard/sectionHeaderModalLogic.ts | 184 ++++++++++++++++++ .../src/scenes/dashboard/tileLayouts.test.ts | 18 ++ frontend/src/scenes/dashboard/tileLayouts.ts | 7 +- posthog/api/test/dashboards/test_dashboard.py | 21 +- .../dashboards/test_dashboard_text_tiles.py | 41 ++++ 14 files changed, 795 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/components/Cards/TextCard/sectionHeader.test.ts create mode 100644 frontend/src/lib/components/Cards/TextCard/sectionHeader.ts create mode 100644 frontend/src/scenes/dashboard/SectionHeaderModal.tsx create mode 100644 frontend/src/scenes/dashboard/items/DashboardSectionHeaderItem.test.tsx create mode 100644 frontend/src/scenes/dashboard/items/DashboardSectionHeaderItem.tsx create mode 100644 frontend/src/scenes/dashboard/sectionHeaderModalLogic.test.ts create mode 100644 frontend/src/scenes/dashboard/sectionHeaderModalLogic.ts diff --git a/frontend/src/lib/components/Cards/TextCard/sectionHeader.test.ts b/frontend/src/lib/components/Cards/TextCard/sectionHeader.test.ts new file mode 100644 index 000000000000..84796ac0e7e7 --- /dev/null +++ b/frontend/src/lib/components/Cards/TextCard/sectionHeader.test.ts @@ -0,0 +1,25 @@ +import { isDashboardSectionHeader, parseDashboardSectionHeader, serializeDashboardSectionHeader } from './sectionHeader' + +describe('dashboard section headers', () => { + it('round-trips user text while retaining a markdown fallback', () => { + const body = serializeDashboardSectionHeader({ + title: 'Activation [weekly]', + description: 'How users reach the **key moment**. 🚀', + }) + + expect(parseDashboardSectionHeader(body)).toEqual({ + title: 'Activation [weekly]', + description: 'How users reach the **key moment**. 🚀', + }) + expect(body).toContain('## Activation \\[weekly\\]') + expect(body).toContain('How users reach the \\*\\*key moment\\*\\*. 🚀') + }) + + it.each(['Regular text', '', ''])( + 'does not identify %p as a section header', + (body) => { + expect(isDashboardSectionHeader(body)).toBe(false) + expect(parseDashboardSectionHeader(body)).toBeNull() + } + ) +}) diff --git a/frontend/src/lib/components/Cards/TextCard/sectionHeader.ts b/frontend/src/lib/components/Cards/TextCard/sectionHeader.ts new file mode 100644 index 000000000000..7ff7da5afdb4 --- /dev/null +++ b/frontend/src/lib/components/Cards/TextCard/sectionHeader.ts @@ -0,0 +1,53 @@ +export interface DashboardSectionHeader { + title: string + description: string +} + +const SECTION_HEADER_PREFIX = '' + +function escapeMarkdown(value: string): string { + return value.replace(/([\\`*_[\]<>#])/g, '\\$1') +} + +export function serializeDashboardSectionHeader({ title, description }: DashboardSectionHeader): string { + const normalizedTitle = title.trim() + const normalizedDescription = description.trim() + const metadata = encodeURIComponent(JSON.stringify({ title: normalizedTitle, description: normalizedDescription })) + const fallback = [`## ${escapeMarkdown(normalizedTitle)}`] + + if (normalizedDescription) { + fallback.push('', escapeMarkdown(normalizedDescription)) + } + + return `${SECTION_HEADER_PREFIX}${metadata}${SECTION_HEADER_SUFFIX}\n${fallback.join('\n')}` +} + +export function parseDashboardSectionHeader(body: string): DashboardSectionHeader | null { + if (!body.startsWith(SECTION_HEADER_PREFIX)) { + return null + } + + const metadataEnd = body.indexOf(SECTION_HEADER_SUFFIX, SECTION_HEADER_PREFIX.length) + if (metadataEnd === -1) { + return null + } + + try { + const metadata = JSON.parse( + decodeURIComponent(body.slice(SECTION_HEADER_PREFIX.length, metadataEnd)) + ) as Partial + + if (typeof metadata.title !== 'string' || typeof metadata.description !== 'string' || !metadata.title.trim()) { + return null + } + + return { title: metadata.title, description: metadata.description } + } catch { + return null + } +} + +export function isDashboardSectionHeader(body: string | undefined): boolean { + return body ? parseDashboardSectionHeader(body) !== null : false +} diff --git a/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx b/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx index 0afde4ea08c5..9d05e41ddec7 100644 --- a/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx +++ b/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx @@ -32,7 +32,7 @@ export function getAddTileMenuItems({ dashboardId: number dashboardWidgetsEnabled: boolean onAddInsight: () => void - push: (url: string) => void + push: (url: string, searchParams?: Record) => void setAddWidgetModalOpen: (open: boolean) => void onBeforeSelect?: () => void }): LemonMenuItems { @@ -49,6 +49,11 @@ export function getAddTileMenuItems({ onClick: withBeforeSelect(onAddInsight), 'data-attr': 'dashboard-add-insight', }, + { + label: 'Section header', + onClick: withBeforeSelect(() => push(urls.dashboardTextTile(dashboardId, 'new'), { sectionHeader: true })), + 'data-attr': 'dashboard-add-section-header', + }, { label: 'Add text', onClick: withBeforeSelect(() => push(urls.dashboardTextTile(dashboardId, 'new'))), diff --git a/frontend/src/scenes/dashboard/DashboardItems.tsx b/frontend/src/scenes/dashboard/DashboardItems.tsx index 274976faa2c8..28ddd895ce31 100644 --- a/frontend/src/scenes/dashboard/DashboardItems.tsx +++ b/frontend/src/scenes/dashboard/DashboardItems.tsx @@ -13,6 +13,7 @@ import { getDashboardWidgetFetchDisplayError } from '@posthog/products-dashboard import { ApiError } from 'lib/api' import { InsightCard } from 'lib/components/Cards/InsightCard' import { EditModeEdge, useResizeHandleScrollbarPassThrough } from 'lib/components/Cards/InsightCard/EditModeEdgeOverlay' +import { parseDashboardSectionHeader } from 'lib/components/Cards/TextCard/sectionHeader' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonMenuItems } from 'lib/lemon-ui/LemonMenu' import { DashboardEventSource, eventUsageLogic } from 'lib/utils/eventUsageLogic' @@ -38,6 +39,7 @@ import { DashboardLayoutSize, DashboardMode, DashboardPlacement, DashboardType } import { DashboardButtonTileItem } from './items/DashboardButtonTileItem' import { DashboardErrorTileItem } from './items/DashboardErrorTileItem' +import { DashboardSectionHeaderItem } from './items/DashboardSectionHeaderItem' import { DashboardTextItem } from './items/DashboardTextItem' const DRAG_AUTO_SCROLL_THRESHOLD = 100 @@ -68,6 +70,10 @@ function gridTilePropsEqual(prevProps: Record, nextProps: Record { + if (dashboard?.id) { + push(urls.dashboardTextTile(dashboard.id, tile.id)) + } + }} + onMoveToDashboard={commonTileProps.moveToDashboard} + onCopyToDashboard={commonTileProps.copyToDashboard} + onDuplicate={() => duplicateTile(tile)} + onRemove={commonTileProps.removeFromDashboard} + showResizeHandles={commonTileProps.showResizeHandles} + showEditingControls={commonTileProps.showEditingControls} + canEnterEditModeFromEdge={commonTileProps.canEnterEditModeFromEdge} + onEnterEditModeFromEdge={commonTileProps.onEnterEditModeFromEdge} + onDragHandleMouseDown={commonTileProps.onDragHandleMouseDown} + /> + ) + } + return ( }): JSX.Element { const { @@ -39,7 +41,13 @@ export function DashboardModals({ dashboard }: { dashboard: DashboardType tile.id === textTileId) + const isSectionHeaderModal = + searchParams.sectionHeader === 'true' || + searchParams.sectionHeader === true || + !!parseDashboardSectionHeader(selectedTextTile?.text?.body || '') return ( <> @@ -59,12 +67,21 @@ export function DashboardModals({ dashboard }: { dashboard: DashboardType {canEditDashboard && ( <> - push(urls.dashboard(dashboard.id))} - dashboard={dashboard} - textTileId={textTileId} - /> + {isSectionHeaderModal ? ( + push(urls.dashboard(dashboard.id))} + dashboard={dashboard} + textTileId={textTileId} + /> + ) : ( + push(urls.dashboard(dashboard.id))} + dashboard={dashboard} + textTileId={textTileId} + /> + )} push(urls.dashboard(dashboard.id))} diff --git a/frontend/src/scenes/dashboard/SectionHeaderModal.tsx b/frontend/src/scenes/dashboard/SectionHeaderModal.tsx new file mode 100644 index 000000000000..3e6d744277e9 --- /dev/null +++ b/frontend/src/scenes/dashboard/SectionHeaderModal.tsx @@ -0,0 +1,85 @@ +import { useActions, useValues } from 'kea' +import { Field, Form } from 'kea-forms' + +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { LemonInput } from 'lib/lemon-ui/LemonInput' +import { LemonModal } from 'lib/lemon-ui/LemonModal' +import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea' + +import { DashboardType, QueryBasedInsightModel } from '~/types' + +import { sectionHeaderModalLogic } from './sectionHeaderModalLogic' + +export interface SectionHeaderModalProps { + isOpen: boolean + onClose: () => void + dashboard: DashboardType + textTileId: number | 'new' | null +} + +export function SectionHeaderModal({ isOpen, onClose, dashboard, textTileId }: SectionHeaderModalProps): JSX.Element { + const resolvedTileId = textTileId ?? 'new' + const logicProps = { dashboard, textTileId: resolvedTileId, onClose } + const logic = sectionHeaderModalLogic(logicProps) + const { isSectionHeaderSubmitting, sectionHeaderValidationErrors } = useValues(logic) + const { resetSectionHeader } = useActions(logic) + + const handleClose = (): void => { + resetSectionHeader() + onClose() + } + + const firstError = sectionHeaderValidationErrors.title || sectionHeaderValidationErrors.description + + return ( + + + Cancel + + + Save + + + } + > +
+
+ + + + + + +
+
+
+ ) +} diff --git a/frontend/src/scenes/dashboard/items/DashboardSectionHeaderItem.test.tsx b/frontend/src/scenes/dashboard/items/DashboardSectionHeaderItem.test.tsx new file mode 100644 index 000000000000..25bc2d054841 --- /dev/null +++ b/frontend/src/scenes/dashboard/items/DashboardSectionHeaderItem.test.tsx @@ -0,0 +1,57 @@ +import '@testing-library/jest-dom' + +import { fireEvent, render } from '@testing-library/react' + +import { initKeaTests } from '~/test/init' +import { DashboardPlacement, DashboardTile, QueryBasedInsightModel } from '~/types' + +import { DashboardSectionHeaderItem } from './DashboardSectionHeaderItem' + +const tile = { + id: 1, + text: { body: 'stored body', last_modified_at: '2024-01-01T00:00:00Z' }, + layouts: {}, + color: null, +} as DashboardTile + +describe('DashboardSectionHeaderItem', () => { + beforeEach(() => { + initKeaTests() + }) + + it('renders user text literally without editing controls on public dashboards', () => { + const { container, getByText, queryByLabelText } = render( + ', description: '**Literal description**' }} + placement={DashboardPlacement.Public} + onEdit={jest.fn()} + onDuplicate={jest.fn()} + /> + ) + + expect(getByText('')).toBeInTheDocument() + expect(getByText('**Literal description**')).toBeInTheDocument() + expect(container.querySelector('img')).toBeNull() + expect(queryByLabelText('more')).not.toBeInTheDocument() + }) + + it('offers existing tile actions on editable dashboards', () => { + const { getByLabelText, getByText } = render( + + ) + + fireEvent.click(getByLabelText('more')) + expect(getByText('Edit section header')).toBeInTheDocument() + expect(getByText('Duplicate')).toBeInTheDocument() + expect(getByText('Delete')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/scenes/dashboard/items/DashboardSectionHeaderItem.tsx b/frontend/src/scenes/dashboard/items/DashboardSectionHeaderItem.tsx new file mode 100644 index 000000000000..7749330abe0c --- /dev/null +++ b/frontend/src/scenes/dashboard/items/DashboardSectionHeaderItem.tsx @@ -0,0 +1,132 @@ +import clsx from 'clsx' +import { useValues } from 'kea' +import React from 'react' + +import { Resizeable } from 'lib/components/Cards/CardMeta' +import { DashboardResizeHandles } from 'lib/components/Cards/handles' +import { dashboardWidgetMenusLogic } from 'lib/components/Cards/InsightCard/dashboardWidgetMenusLogic' +import { DashboardWidgetPlacementMenus } from 'lib/components/Cards/InsightCard/DashboardWidgetPlacementMenus' +import { EditModeEdge, EditModeEdgeOverlay } from 'lib/components/Cards/InsightCard/EditModeEdgeOverlay' +import { DashboardSectionHeader } from 'lib/components/Cards/TextCard/sectionHeader' +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { More } from 'lib/lemon-ui/LemonButton/More' +import { LemonDivider } from 'lib/lemon-ui/LemonDivider' + +import { DashboardPlacement, DashboardTile, DashboardType, QueryBasedInsightModel } from '~/types' + +export interface DashboardSectionHeaderItemProps extends React.HTMLAttributes, Resizeable { + tile: DashboardTile + sectionHeader: DashboardSectionHeader + placement: DashboardPlacement + dashboardId?: number | null + onEdit: () => void + onMoveToDashboard?: (target: Pick) => void + onCopyToDashboard?: (target: Pick) => void + onDuplicate: () => void + onRemove?: () => void + canEnterEditModeFromEdge?: boolean + onEnterEditModeFromEdge?: (event: React.MouseEvent, edge: EditModeEdge) => void + onDragHandleMouseDown?: React.MouseEventHandler + showEditingControls?: boolean +} + +function DashboardSectionHeaderItemInternal( + { + tile, + sectionHeader, + placement, + dashboardId, + onEdit, + onMoveToDashboard, + onCopyToDashboard, + onDuplicate, + onRemove, + showResizeHandles, + canEnterEditModeFromEdge, + onEnterEditModeFromEdge, + onDragHandleMouseDown, + showEditingControls, + className, + ...divProps + }: DashboardSectionHeaderItemProps, + ref: React.ForwardedRef +): JSX.Element { + const textId = tile.text?.id + const { copyToDestinations } = useValues( + dashboardWidgetMenusLogic({ + instanceKey: textId != null ? `section-header-${textId}` : `section-header-tile-${tile.id}`, + dashboardId, + dashboards: undefined, + dashboard_tiles: tile.text?.dashboard_tiles, + }) + ) + const shouldHideMoreButton = placement === DashboardPlacement.Public || showEditingControls === false + + return ( +
+ {!shouldHideMoreButton && ( +
+ + + Edit section header + + + + Duplicate + + + {onRemove && ( + + Delete + + )} + + } + /> +
+ )} +
+

{sectionHeader.title}

+ {sectionHeader.description && ( +

+ {sectionHeader.description} +

+ )} +
+ {canEnterEditModeFromEdge && !showResizeHandles && onEnterEditModeFromEdge && ( + + )} + {showResizeHandles && } +
+ ) +} + +export const DashboardSectionHeaderItem = React.forwardRef( + DashboardSectionHeaderItemInternal +) diff --git a/frontend/src/scenes/dashboard/sectionHeaderModalLogic.test.ts b/frontend/src/scenes/dashboard/sectionHeaderModalLogic.test.ts new file mode 100644 index 000000000000..1daf329efb36 --- /dev/null +++ b/frontend/src/scenes/dashboard/sectionHeaderModalLogic.test.ts @@ -0,0 +1,115 @@ +import { api } from 'lib/api.mock' + +import { expectLogic } from 'kea-test-utils' + +import { + parseDashboardSectionHeader, + serializeDashboardSectionHeader, +} from 'lib/components/Cards/TextCard/sectionHeader' + +import { initKeaTests } from '~/test/init' +import { AccessControlLevel, DashboardType, QueryBasedInsightModel } from '~/types' + +import { sectionHeaderModalLogic } from './sectionHeaderModalLogic' + +const makeDashboard = (): DashboardType => + ({ + id: 123, + name: 'Test dashboard', + description: '', + pinned: false, + created_at: '2024-01-01T00:00:00Z', + created_by: null, + last_accessed_at: null, + is_shared: false, + deleted: false, + creation_mode: 'default', + tiles: [ + { + id: 1, + color: null, + layouts: { sm: { x: 0, y: 0, w: 12, h: 5 } }, + text: { + body: serializeDashboardSectionHeader({ + title: 'Existing section', + description: 'Existing description', + }), + last_modified_at: '2024-01-01T00:00:00Z', + }, + transparent_background: true, + }, + ], + filters: {}, + tags: [], + user_access_level: AccessControlLevel.Editor, + }) as unknown as DashboardType + +describe('sectionHeaderModalLogic', () => { + beforeEach(() => { + initKeaTests() + jest.spyOn(api, 'update').mockResolvedValue(makeDashboard()) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('creates a transparent full-width section on a new bottom row', async () => { + const logic = sectionHeaderModalLogic({ dashboard: makeDashboard(), textTileId: 'new', onClose: jest.fn() }) + logic.mount() + + logic.actions.setSectionHeaderValues({ title: 'Activation', description: 'Users reaching value.' }) + logic.actions.submitSectionHeader() + await expectLogic(logic).toFinishAllListeners() + + const update = api.update.mock.calls[0][1] as Record + expect(update.tiles[0]).toMatchObject({ + transparent_background: true, + layouts: { sm: { x: 0, y: 5, w: 12, h: 1 } }, + }) + expect(parseDashboardSectionHeader(update.tiles[0].text.body)).toEqual({ + title: 'Activation', + description: 'Users reaching value.', + }) + }) + + it('edits the existing text tile without changing its identity', async () => { + const logic = sectionHeaderModalLogic({ dashboard: makeDashboard(), textTileId: 1, onClose: jest.fn() }) + logic.mount() + + await expectLogic(logic).toMatchValues({ + sectionHeader: { title: 'Existing section', description: 'Existing description' }, + }) + logic.actions.setSectionHeaderValues({ title: 'Updated section', description: '' }) + logic.actions.submitSectionHeader() + await expectLogic(logic).toFinishAllListeners() + + const tile = (api.update.mock.calls[0][1] as Record).tiles[0] + expect(tile.id).toBe(1) + expect(tile.transparent_background).toBe(true) + expect(parseDashboardSectionHeader(tile.text.body)).toEqual({ + title: 'Updated section', + description: '', + }) + }) + + it('requires a title', async () => { + const logic = sectionHeaderModalLogic({ dashboard: makeDashboard(), textTileId: 'new', onClose: jest.fn() }) + logic.mount() + + await expectLogic(logic).toMatchValues({ + sectionHeaderValidationErrors: { title: 'Enter a section title', description: null }, + }) + }) + + it('rejects content that exceeds text tile storage after encoding', async () => { + const logic = sectionHeaderModalLogic({ dashboard: makeDashboard(), textTileId: 'new', onClose: jest.fn() }) + logic.mount() + + logic.actions.setSectionHeaderValues({ title: 'Activation', description: 'æ¼¢'.repeat(500) }) + + await expectLogic(logic).toMatchValues({ + sectionHeaderValidationErrors: { title: null, description: 'Shorten the title or description' }, + }) + }) +}) diff --git a/frontend/src/scenes/dashboard/sectionHeaderModalLogic.ts b/frontend/src/scenes/dashboard/sectionHeaderModalLogic.ts new file mode 100644 index 000000000000..8341eab6d190 --- /dev/null +++ b/frontend/src/scenes/dashboard/sectionHeaderModalLogic.ts @@ -0,0 +1,184 @@ +import { MakeLogicType, connect, kea, key, listeners, path, props } from 'kea' +import { forms } from 'kea-forms' +import type { DeepPartial, DeepPartialMap, FieldName, ValidationErrorType } from 'kea-forms' +import posthog from 'posthog-js' + +import { lemonToast } from '@posthog/lemon-ui' + +import { + parseDashboardSectionHeader, + serializeDashboardSectionHeader, +} from 'lib/components/Cards/TextCard/sectionHeader' + +import { dashboardsModel } from '~/models/dashboardsModel' +import { DashboardType, QueryBasedInsightModel } from '~/types' + +import { calculateLayouts, defaultSmLayoutAtBottom } from './tileLayouts' + +export interface SectionHeaderForm { + title: string + description: string +} + +export interface SectionHeaderModalLogicProps { + dashboard: DashboardType + textTileId: number | 'new' + onClose: () => void +} + +const MAX_SECTION_HEADER_TITLE_LENGTH = 120 +const MAX_SECTION_HEADER_DESCRIPTION_LENGTH = 500 +const MAX_TEXT_TILE_BODY_LENGTH = 4000 + +function getExistingSectionHeader( + dashboard: DashboardType, + textTileId: number +): SectionHeaderForm { + const body = dashboard.tiles?.find((tile) => tile.id === textTileId)?.text?.body + return (body && parseDashboardSectionHeader(body)) || { title: '', description: '' } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface sectionHeaderModalLogicValues { + isSectionHeaderSubmitting: boolean + isSectionHeaderValid: boolean + sectionHeader: SectionHeaderForm + sectionHeaderAllErrors: Record + sectionHeaderChanged: boolean + sectionHeaderErrors: DeepPartialMap + sectionHeaderHasErrors: boolean + sectionHeaderManualErrors: Record + sectionHeaderTouched: boolean + sectionHeaderTouches: Record + sectionHeaderValidationErrors: DeepPartialMap + showSectionHeaderErrors: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface sectionHeaderModalLogicActions { + updateDashboard: (args_0: any) => any // dashboardsModel + resetSectionHeader: (values?: SectionHeaderForm) => { + values?: SectionHeaderForm + } + setSectionHeaderManualErrors: (errors: Record) => { + errors: Record + } + setSectionHeaderValue: ( + key: FieldName, + value: any + ) => { + name: FieldName + value: any + } + setSectionHeaderValues: (values: DeepPartial) => { + values: DeepPartial + } + submitSectionHeader: () => { + value: boolean + } + submitSectionHeaderFailure: ( + error: Error, + errors: Record + ) => { + error: Error + errors: Record + } + submitSectionHeaderRequest: (sectionHeader: SectionHeaderForm) => { + sectionHeader: SectionHeaderForm + } + submitSectionHeaderSuccess: (sectionHeader: SectionHeaderForm) => { + sectionHeader: SectionHeaderForm + } + touchSectionHeaderField: (key: string) => { + key: string + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface sectionHeaderModalLogicMeta { + key: string +} + +export type sectionHeaderModalLogicType = MakeLogicType< + sectionHeaderModalLogicValues, + sectionHeaderModalLogicActions, + SectionHeaderModalLogicProps, + sectionHeaderModalLogicMeta +> + +export const sectionHeaderModalLogic = kea([ + path(['scenes', 'dashboard', 'sectionHeaderModalLogic']), + props({} as SectionHeaderModalLogicProps), + key((props) => `sectionHeaderModalLogic-${props.dashboard.id}-${props.textTileId}`), + connect(() => ({ actions: [dashboardsModel, ['updateDashboard']] })), + listeners(({ props, actions }) => ({ + submitSectionHeaderFailure: ({ error }) => { + lemonToast.error(`Could not save section header: ${error.message}`) + }, + submitSectionHeaderSuccess: ({ sectionHeader }: { sectionHeader: SectionHeaderForm }) => { + actions.resetSectionHeader() + props.onClose() + posthog.capture('dashboard section header saved', { + dashboard_id: props.dashboard.id, + text_tile_id: props.textTileId === 'new' ? null : props.textTileId, + is_new: props.textTileId === 'new', + has_description: !!sectionHeader.description.trim(), + }) + }, + })), + forms(({ props, actions }) => ({ + sectionHeader: { + defaults: (props.textTileId !== 'new' + ? getExistingSectionHeader(props.dashboard, props.textTileId) + : { title: '', description: '' }) as SectionHeaderForm, + errors: ({ title, description }) => ({ + title: !title.trim() + ? 'Enter a section title' + : title.length > MAX_SECTION_HEADER_TITLE_LENGTH + ? `Title is too long (${MAX_SECTION_HEADER_TITLE_LENGTH} characters max)` + : null, + description: + description.length > MAX_SECTION_HEADER_DESCRIPTION_LENGTH + ? `Description is too long (${MAX_SECTION_HEADER_DESCRIPTION_LENGTH} characters max)` + : serializeDashboardSectionHeader({ title, description }).length > MAX_TEXT_TILE_BODY_LENGTH + ? 'Shorten the title or description' + : null, + }), + submit: (formValues) => { + const body = serializeDashboardSectionHeader(formValues) + + if (props.textTileId === 'new') { + const layouts = calculateLayouts([...(props.dashboard.tiles || [])]) + actions.updateDashboard({ + id: props.dashboard.id, + tiles: [ + { + text: { body }, + transparent_background: true, + layouts: { sm: defaultSmLayoutAtBottom(layouts.sm, 12, 1) }, + }, + ], + }) + return + } + + const tile = props.dashboard.tiles?.find((candidate) => candidate.id === props.textTileId) + if (!tile?.text) { + lemonToast.error('Could not find this section header') + return + } + + actions.updateDashboard({ + id: props.dashboard.id, + tiles: [ + { + id: tile.id, + text: { ...tile.text, body }, + transparent_background: true, + }, + ], + }) + }, + }, + })), +]) diff --git a/frontend/src/scenes/dashboard/tileLayouts.test.ts b/frontend/src/scenes/dashboard/tileLayouts.test.ts index d285c5dddc3f..f4fe5c72af46 100644 --- a/frontend/src/scenes/dashboard/tileLayouts.test.ts +++ b/frontend/src/scenes/dashboard/tileLayouts.test.ts @@ -1,5 +1,6 @@ import { Layout, LayoutItem } from 'react-grid-layout' +import { serializeDashboardSectionHeader } from 'lib/components/Cards/TextCard/sectionHeader' import { calculateDuplicateLayout, calculateInsertionLayout, calculateLayouts } from 'scenes/dashboard/tileLayouts' import { DashboardLayoutSize, DashboardTile, QueryBasedInsightModel, TileLayout } from '~/types' @@ -29,6 +30,23 @@ describe('calculating tile layouts', () => { }) }) + it('defaults section headers to a full-width row at every breakpoint', () => { + const tiles = [ + { + id: 1, + text: { + body: serializeDashboardSectionHeader({ title: 'Acquisition', description: '' }), + }, + layouts: {}, + } as DashboardTile, + ] + + expect(calculateLayouts(tiles)).toEqual({ + sm: [{ i: '1', x: 0, y: 0, w: 12, h: 1, minW: 1, minH: 1 }], + xs: [{ i: '1', x: 0, y: 0, w: 1, h: 1, minW: 1, minH: 1 }], + }) + }) + it('when the tiles have only 2-col layouts, 1 col layout is calculated', () => { // sm layouts have been re-ordered // they are not in creation order when read left to right. diff --git a/frontend/src/scenes/dashboard/tileLayouts.ts b/frontend/src/scenes/dashboard/tileLayouts.ts index 2bcd8dd58212..8b4fbe7b531a 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 { isDashboardSectionHeader } from 'lib/components/Cards/TextCard/sectionHeader' import { BREAKPOINT_COLUMN_COUNTS } from 'scenes/dashboard/dashboardUtils' import { getQueryBasedInsightModel } from '~/queries/nodes/InsightViz/utils' @@ -237,11 +238,15 @@ export const calculateLayouts = ( const layouts = (sortedDashboardTiles || []).map((tile) => { const query = tile.insight ? getQueryBasedInsightModel(tile.insight) : null + const isSectionHeader = isDashboardSectionHeader(tile.text?.body) // Base constraints let defaultW = 6 let defaultH = 5 // Content-adjusted constraints (note that widths should be factors of 12) - if (tile.text) { + if (isSectionHeader) { + defaultW = columnCount + defaultH = 1 + } else if (tile.text) { defaultW = 2 defaultH = 2 } else if (isFunnelsQuery(query)) { diff --git a/posthog/api/test/dashboards/test_dashboard.py b/posthog/api/test/dashboards/test_dashboard.py index 097721938c95..49ede9cc33c0 100644 --- a/posthog/api/test/dashboards/test_dashboard.py +++ b/posthog/api/test/dashboards/test_dashboard.py @@ -2832,9 +2832,22 @@ def test_create_from_template_json_must_provide_at_least_one_tile(self) -> None: assert response.status_code == 400, response.json() def test_create_from_template_json_can_provide_text_tile(self) -> None: + body = ( + "" + "\n## Activation\n\nUsers reaching value." + ) + layouts = {"sm": {"x": 0, "y": 5, "w": 12, "h": 1}} template: dict = { **valid_template, - "tiles": [{"type": "TEXT", "body": "hello world", "layouts": {}}], + "tiles": [ + { + "type": "TEXT", + "body": body, + "layouts": layouts, + "transparent_background": True, + } + ], } response = self.client.post( @@ -2853,12 +2866,12 @@ def test_create_from_template_json_can_provide_text_tile(self) -> None: "insight": None, "is_cached": False, "last_refresh": None, - "layouts": {}, + "layouts": layouts, "order": 0, "show_description": None, "widget": None, "text": { - "body": "hello world", + "body": body, "created_by": None, "dashboard_tiles": [ {"dashboard_id": dashboard_id, "deleted": None, "id": ANY}, @@ -2868,7 +2881,7 @@ def test_create_from_template_json_can_provide_text_tile(self) -> None: "last_modified_by": None, "team": self.team.pk, }, - "transparent_background": None, + "transparent_background": True, }, ] diff --git a/posthog/api/test/dashboards/test_dashboard_text_tiles.py b/posthog/api/test/dashboards/test_dashboard_text_tiles.py index d65535c4af63..af06ad27147f 100644 --- a/posthog/api/test/dashboards/test_dashboard_text_tiles.py +++ b/posthog/api/test/dashboards/test_dashboard_text_tiles.py @@ -294,6 +294,47 @@ def test_can_create_text_tile_with_transparent_background(self) -> None: tile = dashboard_json["tiles"][0] assert tile["transparent_background"] is True + def test_section_header_body_and_layout_survive_create_retrieve_and_edit(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) + body = ( + "" + "\n## Activation\n\nUsers reaching value." + ) + + response = self.client.patch( + f"/api/projects/{self.team.id}/dashboards/{dashboard_id}", + { + "tiles": [ + { + "text": {"body": body}, + "transparent_background": True, + "layouts": {"sm": {"x": 0, "y": 5, "w": 12, "h": 1}}, + } + ] + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + tile = response.json()["tiles"][0] + + retrieved_tile = self.dashboard_api.get_dashboard(dashboard_id)["tiles"][0] + assert retrieved_tile["id"] == tile["id"] + assert retrieved_tile["text"]["body"] == body + assert retrieved_tile["transparent_background"] is True + assert retrieved_tile["layouts"] == {"sm": {"x": 0, "y": 5, "w": 12, "h": 1}} + + updated_body = body.replace("Activation", "Retention") + update_response = self.client.patch( + f"/api/projects/{self.team.id}/dashboards/{dashboard_id}", + {"tiles": [{"id": tile["id"], "text": {**tile["text"], "body": updated_body}}]}, + format="json", + ) + self.assertEqual(update_response.status_code, status.HTTP_200_OK) + updated_tile = update_response.json()["tiles"][0] + assert updated_tile["id"] == tile["id"] + assert updated_tile["text"]["body"] == updated_body + def test_can_update_text_tile_transparent_background(self) -> None: dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) _, dashboard_json = self.dashboard_api.create_text_tile(dashboard_id, text="hello")