forked from PostHog/posthog
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(dashboards): add section header tile to the add menu #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lordspline
wants to merge
4
commits into
master
Choose a base branch
from
capy/feat-dashboard-section-headers
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
49beada
feat(dashboards): add Section header tile as a first-class dashboard …
lordspline 32ee83b
fix(dashboards): make section header save await persistence and round…
lordspline b615d53
fix(dashboards): keep section header route in manifest
lordspline 1b00c7b
fix(dashboards): tie section header save to its own request
lordspline File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
86 changes: 86 additions & 0 deletions
86
frontend/src/lib/components/Cards/SectionHeader/SectionHeaderModal.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
156 changes: 156 additions & 0 deletions
156
frontend/src/lib/components/Cards/SectionHeader/sectionHeaderMarkdown.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
| }) | ||
| }) |
131 changes: 131 additions & 0 deletions
131
frontend/src/lib/components/Cards/SectionHeader/sectionHeaderMarkdown.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.