diff --git a/frontend/src/lib/components/Cards/SectionHeader/SectionHeaderModal.tsx b/frontend/src/lib/components/Cards/SectionHeader/SectionHeaderModal.tsx new file mode 100644 index 000000000000..365ff52bd050 --- /dev/null +++ b/frontend/src/lib/components/Cards/SectionHeader/SectionHeaderModal.tsx @@ -0,0 +1,86 @@ +import { useActions, useValues } from 'kea' +import { Field, Form } from 'kea-forms' + +import { sectionHeaderModalLogic } from 'lib/components/Cards/SectionHeader/sectionHeaderModalLogic' +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/LemonTextArea' + +import { DashboardType, QueryBasedInsightModel } from '~/types' + +export function SectionHeaderModal({ + isOpen, + onClose, + dashboard, + sectionHeaderId, +}: { + isOpen: boolean + onClose: () => void + dashboard: DashboardType + sectionHeaderId: number | 'new' | null +}): JSX.Element { + const resolvedId = sectionHeaderId ?? 'new' + const modalLogicProps = { dashboard, sectionHeaderId: resolvedId, onClose } + const modalLogic = sectionHeaderModalLogic(modalLogicProps) + const { isSectionHeaderSubmitting, sectionHeaderValidationErrors } = useValues(modalLogic) + const { resetSectionHeader } = useActions(modalLogic) + + const handleClose = (): void => { + resetSectionHeader() + onClose() + } + + return ( + + + Cancel + + + Save + + + } + > +
+
+ + + + + + +
+
+
+ ) +} diff --git a/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderMarkdown.test.ts b/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderMarkdown.test.ts new file mode 100644 index 000000000000..4962748429db --- /dev/null +++ b/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderMarkdown.test.ts @@ -0,0 +1,156 @@ +import { + composeSectionHeaderBody, + isSectionHeaderDescriptionInline, + isSectionHeaderTile, + parseSectionHeaderBody, +} from './sectionHeaderMarkdown' + +describe('sectionHeaderMarkdown', () => { + describe('composeSectionHeaderBody', () => { + it('builds a heading, description, and trailing divider', () => { + const body = composeSectionHeaderBody({ + title: 'Acquisition', + description: 'How new users discover and sign up for the product', + }) + expect(body).toContain('## Acquisition') + expect(body).toContain('How new users discover and sign up for the product') + expect(body.endsWith('---')).toBe(true) + }) + + it('omits the description block when no description is given', () => { + const body = composeSectionHeaderBody({ title: 'Conversion', description: '' }) + expect(body).toBe('## Conversion\n\n---') + }) + + it('trims surrounding whitespace and collapses newlines into a single line', () => { + const body = composeSectionHeaderBody({ title: ' Retention ', description: 'weekly\nand monthly' }) + expect(body).toContain('## Retention') + expect(body).toContain('weekly and monthly') + expect(body).not.toContain(' Retention') + }) + + it.each([ + 'Acquisition', + 'Revenue & Growth', + 'Active users (weekly)', + 'Conversion: signup -> paid', + '50% growth target', + 'Week-over-week', + 'C# & .NET', + 'Rate > 50', + 'Roadmap [2026]', + '#growth', + ])('preserves normal punctuation verbatim through a round-trip (%s)', (title) => { + const fields = { title, description: 'supporting copy with (parens), commas & dashes - like this' } + expect(parseSectionHeaderBody(composeSectionHeaderBody(fields))).toEqual(fields) + }) + + it.each([ + ['emphasis in the title', { title: '*Important*', description: 'desc' }], + ['a link in the title', { title: '[Docs](https://example.com)', description: 'desc' }], + ['emphasis in the description', { title: 'Title', description: '*emphasised* copy' }], + ['a link in the description', { title: 'Title', description: 'see [docs](https://example.com)' }], + ])('preserves inline markdown across an edit round-trip (%s)', (_label, fields) => { + // The parsed fields feed straight back into the edit form, so they must re-compose to the same body. + const parsed = parseSectionHeaderBody(composeSectionHeaderBody(fields)) + expect(parsed).toEqual(fields) + expect(composeSectionHeaderBody(parsed!)).toBe(composeSectionHeaderBody(fields)) + }) + }) + + describe('isSectionHeaderDescriptionInline', () => { + it.each([ + ['empty', ''], + ['plain text', 'How users sign up'], + ['a hash that is not a heading', '#growth'], + ['inline emphasis', '*important*'], + ['an inline link', '[docs](https://example.com)'], + ['punctuation', 'a & b (c) -> d'], + ])('accepts inline-only descriptions (%s)', (_label, description) => { + expect(isSectionHeaderDescriptionInline(description)).toBe(true) + }) + + it.each([ + ['a thematic break', '---'], + ['asterisk thematic break', '***'], + ['underscore thematic break', '___'], + ['a heading', '# heading'], + ['a bullet list', '- item'], + ['a star list', '* item'], + ['a plus list', '+ item'], + ['an ordered list', '1. item'], + ['a parenthesised ordered list', '2) item'], + ['a blockquote', '> quote'], + ])('rejects descriptions that would become a block (%s)', (_label, description) => { + expect(isSectionHeaderDescriptionInline(description)).toBe(false) + }) + }) + + describe('parseSectionHeaderBody', () => { + it('round-trips composed section headers back into fields', () => { + const fields = { title: 'Engagement', description: 'How activated users come back week over week' } + expect(parseSectionHeaderBody(composeSectionHeaderBody(fields))).toEqual(fields) + }) + + it('parses a heading-only section header', () => { + expect(parseSectionHeaderBody('## Just a title\n\n---')).toEqual({ title: 'Just a title', description: '' }) + }) + + it.each([ + ['empty string', ''], + ['whitespace only', ' '], + ['plain paragraph with no heading', 'just some text'], + ['a section header that lost its trailing divider', '## Title\n\nSome description'], + ['a heading with no divider', '## Title'], + ['an h1 heading instead of h2', '# Title\n\n---'], + ['an h3 heading instead of h2', '### Title\n\n---'], + ['a markdown list', '## Title\n\n- one\n- two\n\n---'], + ['multiple description paragraphs', '## Title\n\nfirst\n\nsecond\n\n---'], + ['a code block', '## Title\n\n```\ncode\n```\n\n---'], + ])('returns null for non-canonical content (%s)', (_label, body) => { + expect(parseSectionHeaderBody(body)).toBeNull() + }) + }) + + describe('isSectionHeaderTile', () => { + const sectionBody = composeSectionHeaderBody({ title: 'Acquisition', description: 'desc' }) + + it('treats a transparent text tile with the canonical section body as a section header', () => { + expect(isSectionHeaderTile({ text: { body: sectionBody }, transparent_background: true } as any)).toBe(true) + }) + + it('ignores a non-transparent text tile', () => { + expect(isSectionHeaderTile({ text: { body: sectionBody }, transparent_background: false } as any)).toBe( + false + ) + }) + + it('ignores a transparent text tile with rich (non-section) content', () => { + expect( + isSectionHeaderTile({ + text: { body: '## Title\n\n- a list\n- of things' }, + transparent_background: true, + } as any) + ).toBe(false) + }) + + it('ignores a transparent heading text card without the trailing divider', () => { + expect( + isSectionHeaderTile({ text: { body: '## Just a heading' }, transparent_background: true } as any) + ).toBe(false) + }) + + it('ignores a transparent text card whose heading is not h2', () => { + expect( + isSectionHeaderTile({ + text: { body: '### [Documentation](https://example.com)\n\n---' }, + transparent_background: true, + } as any) + ).toBe(false) + }) + + it('ignores a tile without text', () => { + expect(isSectionHeaderTile({ transparent_background: true } as any)).toBe(false) + }) + }) +}) diff --git a/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderMarkdown.ts b/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderMarkdown.ts new file mode 100644 index 000000000000..3202ade8aeeb --- /dev/null +++ b/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderMarkdown.ts @@ -0,0 +1,131 @@ +import { JSONContent } from '@tiptap/core' + +import { markdownToTextCardDoc, textCardDocToMarkdown } from 'lib/components/Cards/TextCard/textCardMarkdown' + +import { DashboardTile, QueryBasedInsightModel } from '~/types' + +/** + * A "Section header" is not a distinct database entity — it is an ordinary transparent text tile + * whose markdown body is a single heading, an optional short description paragraph, and a trailing + * horizontal rule that renders as a full-width divider under the header. Composing and parsing both + * go through the shared TextCard markdown pipeline so escaping and round-tripping stay consistent + * with the regular text card editor. Title and description are treated as inline markdown source: + * inline emphasis and links are preserved verbatim across edits, and block-level markdown in the + * description (lists, headings, blockquotes, thematic breaks) is rejected at validation time rather + * than silently corrupted, because a section header is a single heading plus one paragraph. + */ + +export const SECTION_HEADER_HEADING_LEVEL = 2 +export const SECTION_HEADER_MAX_TITLE_LENGTH = 200 +export const SECTION_HEADER_MAX_DESCRIPTION_LENGTH = 300 + +export interface SectionHeaderFields { + title: string + description: string +} + +/** Collapse all whitespace (including newlines) to single spaces so a section stays one heading + one paragraph. */ +function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +/** + * Serialize a block node's inline children back to markdown source, preserving inline marks (emphasis, + * links, code). The children are wrapped in a paragraph and round-tripped through the TextCard markdown + * serializer so the produced source re-parses to the same content — i.e. `*emphasis*` and `[a](b)` survive + * an edit instead of being flattened to plain text. + */ +function nodeInlineToMarkdown(node: JSONContent | undefined): string { + if (!node || !node.content) { + return '' + } + return textCardDocToMarkdown({ type: 'doc', content: [{ type: 'paragraph', content: node.content }] }).trim() +} + +/** + * Whether a description can be stored as the section header's single paragraph. It can iff it parses to + * exactly one paragraph — i.e. it contains only inline content. Block-level markdown such as a list + * (`- item`), heading (`# x`), blockquote (`> x`), or thematic break (`---`) parses to a non-paragraph + * block and would either break the section shape or be corrupted, so those are rejected. Inputs that are + * not actually block syntax (e.g. `#growth`, which Markdown does not parse as a heading) stay valid. + */ +export function isSectionHeaderDescriptionInline(description: string): boolean { + const normalized = normalizeWhitespace(description) + if (!normalized) { + return true + } + const nodes = (markdownToTextCardDoc(normalized).content || []).filter((node) => node.type !== undefined) + return nodes.length === 1 && nodes[0].type === 'paragraph' +} + +/** + * Build the markdown body for a section header from its title and optional description. Inputs are + * normalized to a single line so the body stays a heading plus one paragraph, and a trailing `---` divider + * is appended so the rendered tile shows a full-width rule under the header. Title and description are + * emitted as inline markdown source; callers should validate the description with + * {@link isSectionHeaderDescriptionInline} first so it cannot turn into a block and break the shape. + */ +export function composeSectionHeaderBody({ title, description }: SectionHeaderFields): string { + const normalizedTitle = normalizeWhitespace(title) + const normalizedDescription = normalizeWhitespace(description) + + const headingPrefix = '#'.repeat(SECTION_HEADER_HEADING_LEVEL) + const blocks = [`${headingPrefix} ${normalizedTitle}`.trimEnd()] + if (normalizedDescription) { + blocks.push(normalizedDescription) + } + blocks.push('---') + return blocks.join('\n\n') +} + +/** + * Parse a markdown body back into section header fields, or return null when the body is not the exact + * signature emitted by {@link composeSectionHeaderBody}: a level-2 heading, an optional single description + * paragraph, and a required trailing horizontal rule — nothing else. Requiring the canonical signature + * keeps ordinary transparent text cards (other heading levels, no divider, richer content) out of the + * compact section editor. Inline marks are serialized back to markdown source so edits are lossless. + */ +export function parseSectionHeaderBody(body: string | null | undefined): SectionHeaderFields | null { + if (!body || !body.trim()) { + return null + } + + const doc = markdownToTextCardDoc(body) + const nodes = (doc.content || []).filter((node) => node.type !== undefined) + + // A canonical section header always ends with the divider (a single trailing horizontal rule). + if (nodes.length === 0 || nodes[nodes.length - 1].type !== 'horizontalRule') { + return null + } + const core = nodes.slice(0, -1) + if (core.length < 1 || core.length > 2) { + return null + } + + const [headingNode, descriptionNode] = core + if (headingNode.type !== 'heading' || headingNode.attrs?.level !== SECTION_HEADER_HEADING_LEVEL) { + return null + } + if (descriptionNode && descriptionNode.type !== 'paragraph') { + return null + } + + return { + title: nodeInlineToMarkdown(headingNode), + description: nodeInlineToMarkdown(descriptionNode), + } +} + +/** + * Whether a dashboard tile should be treated as a section header: a transparent text tile whose body + * round-trips to the canonical section header signature. Used to route editing to the compact form and to + * label the tile; the visual treatment itself comes entirely from the composed markdown. + */ +export function isSectionHeaderTile( + tile: Pick, 'text' | 'transparent_background'> | null | undefined +): boolean { + if (!tile?.text?.body || tile.transparent_background !== true) { + return false + } + return parseSectionHeaderBody(tile.text.body) !== null +} diff --git a/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderModalLogic.test.ts b/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderModalLogic.test.ts new file mode 100644 index 000000000000..db48b1ce5e61 --- /dev/null +++ b/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderModalLogic.test.ts @@ -0,0 +1,262 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { lemonToast } from '@posthog/lemon-ui' + +import api from 'lib/api' +import { composeSectionHeaderBody } from 'lib/components/Cards/SectionHeader/sectionHeaderMarkdown' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { AccessControlLevel, DashboardTile, DashboardType, QueryBasedInsightModel } from '~/types' + +import { sectionHeaderDefaultLayouts, sectionHeaderModalLogic } from './sectionHeaderModalLogic' + +const DASHBOARD_ID = 123 + +const makeDashboard = ( + tiles: Partial>[] +): DashboardType => + ({ + id: DASHBOARD_ID, + 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, + filters: {}, + tags: [], + user_access_level: AccessControlLevel.Editor, + }) as DashboardType + +const insightTile = (id: number, y: number, h: number): Partial> => ({ + id, + layouts: { sm: { x: 0, y, w: 6, h }, xs: { x: 0, y, w: 1, h } }, +}) + +const sectionTile = ( + id: number, + fields: { title: string; description: string } +): Partial> => ({ + id, + transparent_background: true, + layouts: { sm: { x: 0, y: 0, w: 12, h: 2 }, xs: { x: 0, y: 0, w: 1, h: 2 } }, + text: { body: composeSectionHeaderBody(fields), last_modified_at: '2024-01-01T00:00:00Z' }, +}) + +describe('sectionHeaderDefaultLayouts', () => { + it('spans the full 12-column grid and starts a new row below every tile', () => { + const layouts = sectionHeaderDefaultLayouts([insightTile(1, 0, 5), insightTile(2, 5, 4)]) + expect(layouts.sm).toEqual({ x: 0, y: 9, w: 12, h: 2 }) + }) + + it('places the first section at the top of an empty dashboard', () => { + expect(sectionHeaderDefaultLayouts([]).sm).toEqual({ x: 0, y: 0, w: 12, h: 2 }) + }) + + it('uses a single column on narrow (xs) dashboards', () => { + const layouts = sectionHeaderDefaultLayouts([insightTile(1, 0, 5)]) + expect(layouts.xs).toEqual({ x: 0, y: 5, w: 1, h: 2 }) + }) +}) + +describe('sectionHeaderModalLogic', () => { + beforeEach(() => { + initKeaTests() + useMocks({ + patch: { + '/api/environments/:team_id/dashboards/:id/': () => [200, makeDashboard([])], + }, + }) + jest.spyOn(lemonToast, 'error').mockImplementation(jest.fn()) + jest.spyOn(posthog, 'capture').mockImplementation(jest.fn() as any) + jest.spyOn(api, 'update') + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('parses an existing section tile into the title and description fields', async () => { + const logic = sectionHeaderModalLogic({ + dashboard: makeDashboard([sectionTile(1, { title: 'Acquisition', description: 'How users sign up' })]), + sectionHeaderId: 1, + onClose: jest.fn(), + }) + logic.mount() + + await expectLogic(logic).toMatchValues({ + sectionHeader: { title: 'Acquisition', description: 'How users sign up' }, + }) + }) + + it('requires a title', async () => { + const logic = sectionHeaderModalLogic({ + dashboard: makeDashboard([]), + sectionHeaderId: 'new', + onClose: jest.fn(), + }) + logic.mount() + + await expectLogic(logic).toMatchValues({ + sectionHeaderValidationErrors: { title: 'Give the section a title', description: null }, + }) + }) + + it('rejects an over-long title', async () => { + const logic = sectionHeaderModalLogic({ + dashboard: makeDashboard([]), + sectionHeaderId: 'new', + onClose: jest.fn(), + }) + logic.mount() + logic.actions.setSectionHeaderValue('title', 'x'.repeat(201)) + + await expectLogic(logic).toMatchValues({ + sectionHeaderValidationErrors: { title: 'Title is too long (200 characters max)', description: null }, + }) + }) + + it('rejects an over-long description', async () => { + const logic = sectionHeaderModalLogic({ + dashboard: makeDashboard([]), + sectionHeaderId: 'new', + onClose: jest.fn(), + }) + logic.mount() + logic.actions.setSectionHeaderValues({ title: 'Fine', description: 'x'.repeat(301) }) + + await expectLogic(logic).toMatchValues({ + sectionHeaderValidationErrors: { + title: null, + description: 'Description is too long (300 characters max)', + }, + }) + }) + + it('rejects a description that would become a markdown block', async () => { + const logic = sectionHeaderModalLogic({ + dashboard: makeDashboard([]), + sectionHeaderId: 'new', + onClose: jest.fn(), + }) + logic.mount() + logic.actions.setSectionHeaderValues({ title: 'Fine', description: '- a bullet line' }) + + await expectLogic(logic).toMatchValues({ + sectionHeaderValidationErrors: { + title: null, + description: 'Remove block formatting (like -, >, #, or ---) from the start of the description', + }, + }) + }) + + it('creates a transparent, full-width section header tile and resets the form on success', async () => { + const onClose = jest.fn() + const logic = sectionHeaderModalLogic({ + dashboard: makeDashboard([insightTile(1, 0, 5)]), + sectionHeaderId: 'new', + onClose, + }) + logic.mount() + logic.actions.setSectionHeaderValues({ title: 'Engagement', description: 'Weekly retention' }) + + await expectLogic(logic, () => { + logic.actions.submitSectionHeader() + }).toDispatchActions(['submitSectionHeader', 'submitSectionHeaderSuccess']) + + expect(api.update).toHaveBeenCalledWith( + expect.stringContaining(`dashboards/${DASHBOARD_ID}`), + expect.objectContaining({ + tiles: [ + expect.objectContaining({ + text: { + body: composeSectionHeaderBody({ title: 'Engagement', description: 'Weekly retention' }), + }, + transparent_background: true, + layouts: expect.objectContaining({ sm: { x: 0, y: 5, w: 12, h: 2 } }), + }), + ], + }) + ) + expect(posthog.capture).toHaveBeenCalledWith( + 'dashboard section header saved', + expect.objectContaining({ dashboard_id: DASHBOARD_ID, is_new: true, has_description: true }) + ) + expect(onClose).toHaveBeenCalledTimes(1) + // The keyed logic is shared with the next create, so a successful save must clear the form. + await expectLogic(logic).toMatchValues({ sectionHeader: { title: '', description: '' } }) + }) + + it('updates an existing section header in place without changing its layout', async () => { + const logic = sectionHeaderModalLogic({ + dashboard: makeDashboard([sectionTile(7, { title: 'Old', description: 'old desc' })]), + sectionHeaderId: 7, + onClose: jest.fn(), + }) + logic.mount() + logic.actions.setSectionHeaderValue('title', 'New title') + + await expectLogic(logic, () => { + logic.actions.submitSectionHeader() + }).toDispatchActions(['submitSectionHeader', 'submitSectionHeaderSuccess']) + + const updateCall = (api.update as jest.Mock).mock.calls.at(-1) + const payload = updateCall?.[1] as { + tiles: { id: number; transparent_background: boolean; layouts?: unknown }[] + } + expect(payload.tiles[0]).toEqual( + expect.objectContaining({ + id: 7, + transparent_background: true, + }) + ) + expect(payload.tiles[0].layouts).toBeUndefined() + }) + + it('keeps the modal open and toasts when the save fails', async () => { + useMocks({ + patch: { + '/api/environments/:team_id/dashboards/:id/': () => [500, { detail: 'server exploded' }], + }, + }) + const onClose = jest.fn() + const logic = sectionHeaderModalLogic({ + dashboard: makeDashboard([insightTile(1, 0, 5)]), + sectionHeaderId: 'new', + onClose, + }) + logic.mount() + logic.actions.setSectionHeaderValues({ title: 'Engagement', description: 'Weekly retention' }) + + await expectLogic(logic, () => { + logic.actions.submitSectionHeader() + }).toDispatchActions(['submitSectionHeader', 'submitSectionHeaderFailure']) + + expect(onClose).not.toHaveBeenCalled() + expect(lemonToast.error).toHaveBeenCalled() + expect(posthog.capture).not.toHaveBeenCalledWith('dashboard section header saved', expect.anything()) + // The form keeps the user's input so they can retry without retyping. + await expectLogic(logic).toMatchValues({ + sectionHeader: { title: 'Engagement', description: 'Weekly retention' }, + }) + }) + + it('shows a toast for unexpected submit failures', () => { + const logic = sectionHeaderModalLogic({ + dashboard: makeDashboard([]), + sectionHeaderId: 'new', + onClose: jest.fn(), + }) + logic.mount() + + logic.actions.submitSectionHeaderFailure({ error: 'Network error', errors: {} } as any, {}) + + expect(lemonToast.error).toHaveBeenCalledWith('Could not save section header: Network error') + }) +}) diff --git a/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderModalLogic.ts b/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderModalLogic.ts new file mode 100644 index 000000000000..a5685c2b7897 --- /dev/null +++ b/frontend/src/lib/components/Cards/SectionHeader/sectionHeaderModalLogic.ts @@ -0,0 +1,167 @@ +import { connect, kea, key, listeners, path, props } from 'kea' +import { forms } from 'kea-forms' +import posthog from 'posthog-js' + +import { lemonToast } from '@posthog/lemon-ui' + +import api from 'lib/api' +import { + composeSectionHeaderBody, + isSectionHeaderDescriptionInline, + parseSectionHeaderBody, + SECTION_HEADER_MAX_DESCRIPTION_LENGTH, + SECTION_HEADER_MAX_TITLE_LENGTH, +} from 'lib/components/Cards/SectionHeader/sectionHeaderMarkdown' +import { teamLogic } from 'scenes/teamLogic' + +import { refreshTreeItem } from '~/layout/panel-layout/ProjectTree/projectTreeLogic' +import { dashboardsModel, mergeTileTextUpdatesIntoDashboard } from '~/models/dashboardsModel' +import { getQueryBasedDashboard } from '~/queries/nodes/InsightViz/utils' +import { DashboardTile, DashboardType, QueryBasedInsightModel } from '~/types' + +import type { sectionHeaderModalLogicType } from './sectionHeaderModalLogicType' + +export interface SectionHeaderForm { + title: string + description: string +} + +export interface SectionHeaderModalProps { + dashboard: DashboardType + sectionHeaderId: number | 'new' + onClose: () => void +} + +/** Full width on the 12-column desktop grid; two rows so the title, description, and divider all show. */ +export const SECTION_HEADER_DEFAULT_WIDTH = 12 +export const SECTION_HEADER_DEFAULT_HEIGHT = 2 + +export interface SectionHeaderLayouts { + sm: { x: number; y: number; w: number; h: number } + xs: { x: number; y: number; w: number; h: number } +} + +const EMPTY_FORM: SectionHeaderForm = { title: '', description: '' } + +const getExistingSectionHeader = ( + dashboard: DashboardType, + sectionHeaderId: number +): SectionHeaderForm => { + const tile = dashboard.tiles?.find((tt) => tt.id === sectionHeaderId) + return parseSectionHeaderBody(tile?.text?.body) ?? EMPTY_FORM +} + +/** Place a new full-width section header on a fresh row beneath every existing tile. */ +export const sectionHeaderDefaultLayouts = ( + tiles: { layouts?: DashboardTile['layouts'] }[] | null | undefined +): SectionHeaderLayouts => { + let maxBottom = 0 + for (const tile of tiles || []) { + const sm = tile.layouts?.sm + if (sm && typeof sm.y === 'number' && typeof sm.h === 'number') { + maxBottom = Math.max(maxBottom, sm.y + sm.h) + } + } + return { + sm: { x: 0, y: maxBottom, w: SECTION_HEADER_DEFAULT_WIDTH, h: SECTION_HEADER_DEFAULT_HEIGHT }, + xs: { x: 0, y: maxBottom, w: 1, h: SECTION_HEADER_DEFAULT_HEIGHT }, + } +} + +export const sectionHeaderModalLogic = kea([ + path(['scenes', 'dashboard', 'sectionHeaderModal', 'logic']), + props({} as SectionHeaderModalProps), + key((props) => `sectionHeaderModalLogic-${props.dashboard.id}-${props.sectionHeaderId}`), + connect(() => ({ logic: [dashboardsModel] })), + listeners(({ props, actions }) => ({ + submitSectionHeaderFailure: ({ error }: { error?: any }) => { + const message = + (typeof error === 'string' && error) || + (error instanceof Error && error.message) || + (error && typeof error === 'object' && typeof error.error === 'string' && error.error) || + 'Unknown error' + lemonToast.error(`Could not save section header: ${message}`) + }, + submitSectionHeaderSuccess: ({ sectionHeader }: { sectionHeader: SectionHeaderForm }) => { + // Reset before closing so the keyed logic instance (shared between the closed `new` route and the + // next create) reopens empty instead of showing the just-submitted values as an accidental duplicate. + actions.resetSectionHeader() + props?.onClose?.() + + posthog.capture('dashboard section header saved', { + dashboard_id: props.dashboard.id, + section_header_tile_id: props.sectionHeaderId === 'new' ? null : props.sectionHeaderId, + is_new: props.sectionHeaderId === 'new', + title_length: sectionHeader.title.trim().length, + has_description: !!sectionHeader.description.trim(), + }) + }, + })), + forms(({ props }) => ({ + sectionHeader: { + defaults: (props.sectionHeaderId && props.sectionHeaderId !== 'new' + ? getExistingSectionHeader(props.dashboard, props.sectionHeaderId) + : EMPTY_FORM) as SectionHeaderForm, + errors: ({ title, description }) => ({ + title: !title.trim() + ? 'Give the section a title' + : title.trim().length > SECTION_HEADER_MAX_TITLE_LENGTH + ? `Title is too long (${SECTION_HEADER_MAX_TITLE_LENGTH} characters max)` + : null, + description: + description.trim().length > SECTION_HEADER_MAX_DESCRIPTION_LENGTH + ? `Description is too long (${SECTION_HEADER_MAX_DESCRIPTION_LENGTH} characters max)` + : !isSectionHeaderDescriptionInline(description) + ? 'Remove block formatting (like -, >, #, or ---) from the start of the description' + : null, + }), + submit: async (formValues) => { + const body = composeSectionHeaderBody(formValues) + + let tiles: Partial[] | null = null + if (props.sectionHeaderId === 'new') { + tiles = [ + { + text: { body }, + transparent_background: true, + layouts: sectionHeaderDefaultLayouts(props.dashboard.tiles), + } as Partial, + ] + } else { + const existingTile = (props.dashboard.tiles || []).find((t) => t.id === props.sectionHeaderId) + if (existingTile?.text) { + tiles = [ + { + id: existingTile.id, + text: { ...existingTile.text, body }, + transparent_background: true, + } as Partial, + ] + } + } + + if (!tiles) { + return + } + + // Await this specific PATCH so the form lifecycle is tied to its own result. The shared + // dashboardsModel loader can't be awaited safely — it swallows its error and its global + // success/failure actions (also emitted by concurrent layout saves and the sharing toggle) + // carry no request identity. On failure this rejects, so kea-forms keeps the modal open and + // runs submitSectionHeaderFailure; on success we sync the model exactly as the loader would. + const response = await api.update( + `api/environments/${teamLogic.values.currentTeamId}/dashboards/${props.dashboard.id}`, + { tiles } + ) + + const mappedDashboard = getQueryBasedDashboard(response) + if (mappedDashboard) { + dashboardsModel.actions.updateDashboardSuccess( + mergeTileTextUpdatesIntoDashboard(mappedDashboard, tiles) + ) + refreshTreeItem('dashboard', String(props.dashboard.id)) + } + }, + }, + })), +]) diff --git a/frontend/src/products.tsx b/frontend/src/products.tsx index 90b3a2c666ea..a3c613f47569 100644 --- a/frontend/src/products.tsx +++ b/frontend/src/products.tsx @@ -911,6 +911,8 @@ export const productUrls = { combineUrl(`/dashboard/${id}`, highlightInsightId ? { highlightInsightId } : {}).url, dashboardTextTile: (id: string | number, textTileId: string | number): string => `${urls.dashboard(id)}/text-tiles/${textTileId}`, + dashboardSectionHeader: (id: string | number, sectionHeaderId: string | number): string => + `${urls.dashboard(id)}/section-headers/${sectionHeaderId}`, dashboardButtonTile: (id: string | number, buttonTileId: string | number): string => `${urls.dashboard(id)}/button-tiles/${buttonTileId}`, dashboardSharing: (id: string | number): string => `/dashboard/${id}/sharing`, diff --git a/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx b/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx index 2ca397a2fa7e..49123af72648 100644 --- a/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx +++ b/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx @@ -67,6 +67,11 @@ export function DashboardAddTileButton(): JSX.Element | null { onClick: () => push(urls.dashboardTextTile(dashboard.id, 'new')), 'data-attr': 'dashboard-add-text-tile', }, + { + label: 'Section header', + onClick: () => push(urls.dashboardSectionHeader(dashboard.id, 'new')), + 'data-attr': 'dashboard-add-section-header', + }, { label: 'Button', onClick: () => push(urls.dashboardButtonTile(dashboard.id, 'new')), diff --git a/frontend/src/scenes/dashboard/DashboardItems.tsx b/frontend/src/scenes/dashboard/DashboardItems.tsx index f0f81a3387b1..0766c959aebf 100644 --- a/frontend/src/scenes/dashboard/DashboardItems.tsx +++ b/frontend/src/scenes/dashboard/DashboardItems.tsx @@ -12,6 +12,7 @@ import { getDashboardWidgetFetchDisplayError } from '@posthog/products-dashboard import { InsightCard } from 'lib/components/Cards/InsightCard' import { EditModeEdge } from 'lib/components/Cards/InsightCard/EditModeEdgeOverlay' +import { isSectionHeaderTile } from 'lib/components/Cards/SectionHeader/sectionHeaderMarkdown' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { DashboardEventSource, eventUsageLogic } from 'lib/utils/eventUsageLogic' import { dashboardLogic } from 'scenes/dashboard/dashboardLogic' @@ -464,15 +465,21 @@ export function DashboardItems(): JSX.Element { } if (text) { + const isSectionHeader = isSectionHeaderTile(tile) return ( { if (dashboard?.id) { - push(urls.dashboardTextTile(dashboard.id, tile.id)) + push( + isSectionHeader + ? urls.dashboardSectionHeader(dashboard.id, tile.id) + : urls.dashboardTextTile(dashboard.id, tile.id) + ) } }} onMoveToDashboard={commonTileProps.moveToDashboard} diff --git a/frontend/src/scenes/dashboard/DashboardModals.tsx b/frontend/src/scenes/dashboard/DashboardModals.tsx index a9865cbeaffc..7fd80f5168b0 100644 --- a/frontend/src/scenes/dashboard/DashboardModals.tsx +++ b/frontend/src/scenes/dashboard/DashboardModals.tsx @@ -4,6 +4,7 @@ import { router } from 'kea-router' import { AddWidgetModal } from '@posthog/products-dashboards/frontend/widgets/AddWidgetModal' import { ButtonTileCardModal } from 'lib/components/Cards/ButtonTileCard/ButtonTileCardModal' +import { SectionHeaderModal } from 'lib/components/Cards/SectionHeader/SectionHeaderModal' import { TextCardModal } from 'lib/components/Cards/TextCard/TextCardModal' import { SharingModal } from 'lib/components/Sharing/SharingModal' import { SubscriptionsModal } from 'lib/components/Subscriptions/SubscriptionsModal' @@ -28,6 +29,8 @@ export function DashboardModals({ dashboard }: { dashboard: DashboardType + push(urls.dashboard(dashboard.id))} + dashboard={dashboard} + sectionHeaderId={sectionHeaderId} + /> push(urls.dashboard(dashboard.id))} diff --git a/frontend/src/scenes/dashboard/dashboardLogic.tsx b/frontend/src/scenes/dashboard/dashboardLogic.tsx index fc8eba3047d9..e569c0a316d2 100644 --- a/frontend/src/scenes/dashboard/dashboardLogic.tsx +++ b/frontend/src/scenes/dashboard/dashboardLogic.tsx @@ -402,6 +402,7 @@ export const dashboardLogic = kea([ }), setTextTileId: (textTileId: number | 'new' | null) => ({ textTileId }), setButtonTileId: (buttonTileId: number | 'new' | null) => ({ buttonTileId }), + setSectionHeaderId: (sectionHeaderId: number | 'new' | null) => ({ sectionHeaderId }), setTileOverride: (tile: DashboardTile) => ({ tile }), /** @@ -1169,6 +1170,19 @@ export const dashboardLogic = kea([ }, ], + showSectionHeaderModal: [ + false, + { + setSectionHeaderId: (_, { sectionHeaderId }) => !!sectionHeaderId, + }, + ], + sectionHeaderId: [ + null as number | 'new' | null, + { + setSectionHeaderId: (_, { sectionHeaderId }) => sectionHeaderId, + }, + ], + isPinned: [ false, { @@ -3206,6 +3220,7 @@ export const dashboardLogic = kea([ actions.setSubscriptionMode(true, id) actions.setTextTileId(null) actions.setButtonTileId(null) + actions.setSectionHeaderId(null) actions.setDashboardMode(null, DashboardEventSource.Browser) }, @@ -3213,6 +3228,7 @@ export const dashboardLogic = kea([ actions.setSubscriptionMode(false, undefined) actions.setTextTileId(null) actions.setButtonTileId(null) + actions.setSectionHeaderId(null) if (values.dashboardMode === DashboardMode.Sharing) { actions.setDashboardMode(null, DashboardEventSource.Browser) } @@ -3221,21 +3237,33 @@ export const dashboardLogic = kea([ actions.setSubscriptionMode(false, undefined) actions.setTextTileId(null) actions.setButtonTileId(null) + actions.setSectionHeaderId(null) actions.setDashboardMode(DashboardMode.Sharing, DashboardEventSource.Browser) }, '/dashboard/:id/text-tiles/:textTileId': ({ textTileId }) => { actions.setSubscriptionMode(false, undefined) actions.setDashboardMode(null, DashboardEventSource.Browser) actions.setButtonTileId(null) + actions.setSectionHeaderId(null) actions.setTextTileId(textTileId === undefined ? 'new' : textTileId !== 'new' ? Number(textTileId) : 'new') }, '/dashboard/:id/button-tiles/:buttonTileId': ({ buttonTileId }) => { actions.setSubscriptionMode(false, undefined) actions.setDashboardMode(null, DashboardEventSource.Browser) actions.setTextTileId(null) + actions.setSectionHeaderId(null) actions.setButtonTileId( buttonTileId === undefined ? 'new' : buttonTileId !== 'new' ? Number(buttonTileId) : 'new' ) }, + '/dashboard/:id/section-headers/:sectionHeaderId': ({ sectionHeaderId }) => { + actions.setSubscriptionMode(false, undefined) + actions.setDashboardMode(null, DashboardEventSource.Browser) + actions.setTextTileId(null) + actions.setButtonTileId(null) + actions.setSectionHeaderId( + sectionHeaderId === undefined ? 'new' : sectionHeaderId !== 'new' ? Number(sectionHeaderId) : 'new' + ) + }, })), ]) diff --git a/frontend/src/scenes/dashboard/dashboardUtils.ts b/frontend/src/scenes/dashboard/dashboardUtils.ts index d12819faa37a..c51104dcbd90 100644 --- a/frontend/src/scenes/dashboard/dashboardUtils.ts +++ b/frontend/src/scenes/dashboard/dashboardUtils.ts @@ -4,6 +4,7 @@ import { lemonToast } from '@posthog/lemon-ui' import { getDashboardWidgetCatalogEntry } from '@posthog/products-dashboards/frontend/widget_types/catalog' import api, { ApiMethodOptions, getJSONOrNull } from 'lib/api' +import { isSectionHeaderTile } from 'lib/components/Cards/SectionHeader/sectionHeaderMarkdown' import type { Dayjs } from 'lib/dayjs' import { currentSessionId } from 'lib/internalMetrics' import { objectClean, shouldCancelQuery, toParams } from 'lib/utils' @@ -102,7 +103,7 @@ export function getDashboardTileDisplayName(tile: DashboardTile placement: DashboardPlacement dashboardId?: number | null + /** When true, this text tile is a section header; the edit action label reflects that. */ + isSectionHeader?: boolean onEdit: () => void onMoveToDashboard?: (target: Pick) => void onCopyToDashboard?: (target: Pick) => void @@ -27,6 +29,7 @@ function DashboardTextItemInternal( tile, placement, dashboardId, + isSectionHeader, onEdit, onMoveToDashboard, onCopyToDashboard, @@ -54,7 +57,7 @@ function DashboardTextItemInternal( moreButtonOverlay={ <> - Edit text + {isSectionHeader ? 'Edit section header' : 'Edit text'} = { [urls.dashboardTemplateCopyToProject(':sourceTemplateId')]: [Scene.DashboardTemplateCopy, 'dashboardTemplateCopy'], [urls.dashboard(':id')]: [Scene.Dashboard, 'dashboard'], [urls.dashboardTextTile(':id', ':textTileId')]: [Scene.Dashboard, 'dashboardTextTile'], + [urls.dashboardSectionHeader(':id', ':sectionHeaderId')]: [Scene.Dashboard, 'dashboardSectionHeader'], [urls.dashboardButtonTile(':id', ':buttonTileId')]: [Scene.Dashboard, 'dashboardButtonTile'], [urls.dashboardSharing(':id')]: [Scene.Dashboard, 'dashboardSharing'], [urls.dashboardSubscriptions(':id')]: [Scene.Dashboard, 'dashboardSubscriptions'], diff --git a/posthog/api/test/dashboards/test_dashboard_section_headers.py b/posthog/api/test/dashboards/test_dashboard_section_headers.py new file mode 100644 index 000000000000..e3b4376911a1 --- /dev/null +++ b/posthog/api/test/dashboards/test_dashboard_section_headers.py @@ -0,0 +1,124 @@ +from typing import Any + +from posthog.test.base import APIBaseTest, QueryMatchingTest + +from rest_framework import status + +from posthog.api.test.dashboards import DashboardAPI +from posthog.constants import AvailableFeature +from posthog.models.organization import OrganizationMembership + +from ee.models.rbac.access_control import AccessControl + +# A section header is an ordinary transparent text tile whose markdown body is a heading, an optional +# description paragraph, and a trailing divider. There is no section-header model — these tests pin the +# text-tile representation the feature relies on (full-width transparent placement, body persistence, +# duplication, deletion, and serialization). +SECTION_BODY = "## Acquisition\n\nHow new users discover and sign up for the product\n\n---" +SECTION_LAYOUTS = {"sm": {"x": 0, "y": 0, "w": 12, "h": 2}, "xs": {"x": 0, "y": 0, "w": 1, "h": 2}} + + +class TestDashboardSectionHeaders(APIBaseTest, QueryMatchingTest): + def setUp(self) -> None: + super().setUp() + self.dashboard_api = DashboardAPI(self.client, self.team, self.assertEqual) + + def _create_section(self, dashboard_id: int, body: str = SECTION_BODY) -> dict[str, Any]: + _, dashboard = self.dashboard_api.create_text_tile( + dashboard_id, + text=body, + extra_data={"transparent_background": True, "layouts": SECTION_LAYOUTS}, + ) + return next(tile for tile in dashboard["tiles"] if tile.get("text")) + + def test_creates_full_width_transparent_section_header(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "Growth"}) + + section = self._create_section(dashboard_id) + + self.assertEqual(section["transparent_background"], True) + self.assertEqual(section["text"]["body"], SECTION_BODY) + self.assertEqual(section["layouts"]["sm"]["w"], 12) + self.assertEqual(section["layouts"]["sm"]["x"], 0) + self.assertEqual(section["layouts"]["xs"]["w"], 1) + + def test_serializes_section_header_fields(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "Growth"}) + section = self._create_section(dashboard_id) + + dashboard = self.dashboard_api.get_dashboard(dashboard_id) + serialized = next(tile for tile in dashboard["tiles"] if tile["id"] == section["id"]) + + self.assertEqual(serialized["text"]["body"], SECTION_BODY) + self.assertEqual(serialized["transparent_background"], True) + self.assertEqual(serialized["layouts"]["sm"]["w"], 12) + + def test_edits_section_header_body_in_place(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "Growth"}) + section = self._create_section(dashboard_id) + new_body = "## Acquisition\n\nUpdated subtitle\n\n---" + + _, updated = self.dashboard_api.update_text_tile( + dashboard_id, + { + "id": section["id"], + "text": {"id": section["text"]["id"], "body": new_body}, + "transparent_background": True, + }, + ) + + updated_section = next(tile for tile in updated["tiles"] if tile["id"] == section["id"]) + self.assertEqual(updated_section["text"]["body"], new_body) + self.assertEqual(updated_section["transparent_background"], True) + # The body is edited in place — the same Text row is reused, not replaced. + self.assertEqual(updated_section["text"]["id"], section["text"]["id"]) + + def test_duplicating_dashboard_preserves_section_header(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "Growth"}) + section = self._create_section(dashboard_id) + + duplicated = self.client.post( + f"/api/projects/{self.team.id}/dashboards", + {"name": "Growth copy", "use_dashboard": dashboard_id, "duplicate_tiles": True}, + ).json() + + duplicated_section = next(tile for tile in duplicated["tiles"] if tile.get("text")) + self.assertNotEqual(duplicated_section["id"], section["id"]) + self.assertEqual(duplicated_section["transparent_background"], True) + self.assertEqual(duplicated_section["text"]["body"], SECTION_BODY) + self.assertEqual(duplicated_section["layouts"]["sm"]["w"], 12) + + def test_soft_deletes_section_header_tile(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "Growth"}) + section = self._create_section(dashboard_id) + + response = self.client.patch( + f"/api/projects/{self.team.id}/dashboards/{dashboard_id}", + {"tiles": [{"id": section["id"], "deleted": True}]}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + + dashboard = self.dashboard_api.get_dashboard(dashboard_id) + self.assertTrue(all(tile["id"] != section["id"] for tile in dashboard["tiles"])) + + def test_non_editor_cannot_create_section_header(self) -> None: + self.organization.available_product_features = [ + {"key": AvailableFeature.ACCESS_CONTROL, "name": AvailableFeature.ACCESS_CONTROL}, + {"key": AvailableFeature.ROLE_BASED_ACCESS, "name": AvailableFeature.ROLE_BASED_ACCESS}, + ] + self.organization.save() + + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "Growth"}) + AccessControl.objects.create( + resource="dashboard", resource_id=str(dashboard_id), team=self.team, access_level="none" + ) + + viewer = self._create_user("viewer@posthog.com", level=OrganizationMembership.Level.MEMBER) + self.client.force_login(viewer) + + self.dashboard_api.create_text_tile( + dashboard_id, + text=SECTION_BODY, + extra_data={"transparent_background": True, "layouts": SECTION_LAYOUTS}, + expected_status=status.HTTP_403_FORBIDDEN, + ) diff --git a/products/dashboards/manifest.tsx b/products/dashboards/manifest.tsx index 4d434ba08cb8..fc658db784eb 100644 --- a/products/dashboards/manifest.tsx +++ b/products/dashboards/manifest.tsx @@ -14,6 +14,8 @@ export const manifest: ProductManifest = { combineUrl(`/dashboard/${id}`, highlightInsightId ? { highlightInsightId } : {}).url, dashboardTextTile: (id: string | number, textTileId: string | number): string => `${urls.dashboard(id)}/text-tiles/${textTileId}`, + dashboardSectionHeader: (id: string | number, sectionHeaderId: string | number): string => + `${urls.dashboard(id)}/section-headers/${sectionHeaderId}`, dashboardButtonTile: (id: string | number, buttonTileId: string | number): string => `${urls.dashboard(id)}/button-tiles/${buttonTileId}`, dashboardSharing: (id: string | number): string => `/dashboard/${id}/sharing`,