Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<QueryBasedInsightModel>
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 (
<LemonModal
closable={true}
isOpen={isOpen}
title={resolvedId === 'new' ? 'Add section header' : 'Edit section header'}
description="A full-width heading to group tiles into labeled sections."
onClose={handleClose}
footer={
<>
<LemonButton
disabledReason={isSectionHeaderSubmitting ? 'Cannot cancel in progress' : null}
type="secondary"
onClick={handleClose}
>
Cancel
</LemonButton>
<LemonButton
disabledReason={sectionHeaderValidationErrors.title as string | null}
loading={isSectionHeaderSubmitting}
form="section-header-form"
htmlType="submit"
type="primary"
data-attr={resolvedId === 'new' ? 'save-new-section-header' : 'edit-section-header'}
>
Save
</LemonButton>
</>
}
>
<Form
logic={sectionHeaderModalLogic}
props={modalLogicProps}
formKey="sectionHeader"
id="section-header-form"
enableFormOnSubmit
>
<div className="flex flex-col gap-4 w-full max-w-md">
<Field name="title" label="Title">
<LemonInput placeholder="e.g. Acquisition" data-attr="section-header-title" autoFocus />
</Field>
<Field name="description" label="Description">
<LemonTextArea
placeholder="Optional — e.g. How new users discover and sign up for the product"
data-attr="section-header-description"
minRows={1}
maxRows={3}
/>
</Field>
</div>
</Form>
</LemonModal>
)
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Original file line number Diff line number Diff line change
@@ -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<DashboardTile<QueryBasedInsightModel>, 'text' | 'transparent_background'> | null | undefined
): boolean {
if (!tile?.text?.body || tile.transparent_background !== true) {
return false
}
return parseSectionHeaderBody(tile.text.body) !== null
Comment thread
capy-ai[bot] marked this conversation as resolved.
}
Loading