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
25 changes: 25 additions & 0 deletions frontend/src/lib/components/Cards/TextCard/sectionHeader.test.ts
Original file line number Diff line number Diff line change
@@ -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', '<!--posthog-dashboard-section-header:invalid-->', ''])(
'does not identify %p as a section header',
(body) => {
expect(isDashboardSectionHeader(body)).toBe(false)
expect(parseDashboardSectionHeader(body)).toBeNull()
}
)
})
53 changes: 53 additions & 0 deletions frontend/src/lib/components/Cards/TextCard/sectionHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export interface DashboardSectionHeader {
title: string
description: string
}

const SECTION_HEADER_PREFIX = '<!--posthog-dashboard-section-header:'
const SECTION_HEADER_SUFFIX = '-->'

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<DashboardSectionHeader>

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
}
7 changes: 6 additions & 1 deletion frontend/src/scenes/dashboard/DashboardHeaderActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function getAddTileMenuItems({
dashboardId: number
dashboardWidgetsEnabled: boolean
onAddInsight: () => void
push: (url: string) => void
push: (url: string, searchParams?: Record<string, unknown>) => void
setAddWidgetModalOpen: (open: boolean) => void
onBeforeSelect?: () => void
}): LemonMenuItems {
Expand All @@ -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'))),
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/scenes/dashboard/DashboardItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -68,6 +70,10 @@ function gridTilePropsEqual(prevProps: Record<string, any>, nextProps: Record<st

const MemoizedInsightCard = memo(InsightCard, gridTilePropsEqual) as typeof InsightCard
const MemoizedDashboardTextItem = memo(DashboardTextItem, gridTilePropsEqual) as typeof DashboardTextItem
const MemoizedDashboardSectionHeaderItem = memo(
DashboardSectionHeaderItem,
gridTilePropsEqual
) as typeof DashboardSectionHeaderItem
const MemoizedDashboardButtonTileItem = memo(
DashboardButtonTileItem,
gridTilePropsEqual
Expand Down Expand Up @@ -600,6 +606,33 @@ export function DashboardItems({ showCreateAnomalyAlertButton }: DashboardItemsP
}

if (text) {
const sectionHeader = parseDashboardSectionHeader(text.body)
if (sectionHeader) {
return (
<MemoizedDashboardSectionHeaderItem
key={tile.id}
tile={tile}
sectionHeader={sectionHeader}
placement={placement}
dashboardId={dashboard?.id}
onEdit={() => {
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 (
<MemoizedDashboardTextItem
key={tile.id}
Expand Down
29 changes: 23 additions & 6 deletions frontend/src/scenes/dashboard/DashboardModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { parseDashboardSectionHeader } from 'lib/components/Cards/TextCard/sectionHeader'
import { TextCardModal } from 'lib/components/Cards/TextCard/TextCardModal'
import { SharingModal } from 'lib/components/Sharing/SharingModal'
import { TerraformExportModal } from 'lib/components/TerraformExporter/TerraformExportModal'
Expand All @@ -20,6 +21,7 @@ import { dashboardLogic } from './dashboardLogic'
import { DashboardTemplateEditor } from './DashboardTemplateEditor'
import { DeleteDashboardModal } from './DeleteDashboardModal'
import { DuplicateDashboardModal } from './DuplicateDashboardModal'
import { SectionHeaderModal } from './SectionHeaderModal'

export function DashboardModals({ dashboard }: { dashboard: DashboardType<QueryBasedInsightModel> }): JSX.Element {
const {
Expand All @@ -39,7 +41,13 @@ export function DashboardModals({ dashboard }: { dashboard: DashboardType<QueryB
const { setTerraformModalOpen, setAddWidgetModalOpen, addWidgetTiles } = useActions(dashboardLogic)
const { updateDashboardSuccess } = useActions(dashboardsModel)
const { push } = useActions(router)
const { searchParams } = useValues(router)
const { user } = useValues(userLogic)
const selectedTextTile = textTileId === 'new' ? null : dashboard.tiles?.find((tile) => tile.id === textTileId)
const isSectionHeaderModal =
searchParams.sectionHeader === 'true' ||
searchParams.sectionHeader === true ||
!!parseDashboardSectionHeader(selectedTextTile?.text?.body || '')

return (
<>
Expand All @@ -59,12 +67,21 @@ export function DashboardModals({ dashboard }: { dashboard: DashboardType<QueryB
/>
{canEditDashboard && (
<>
<TextCardModal
isOpen={showTextTileModal}
onClose={() => push(urls.dashboard(dashboard.id))}
dashboard={dashboard}
textTileId={textTileId}
/>
{isSectionHeaderModal ? (
<SectionHeaderModal
isOpen={showTextTileModal}
onClose={() => push(urls.dashboard(dashboard.id))}
dashboard={dashboard}
textTileId={textTileId}
/>
) : (
<TextCardModal
isOpen={showTextTileModal}
onClose={() => push(urls.dashboard(dashboard.id))}
dashboard={dashboard}
textTileId={textTileId}
/>
)}
<ButtonTileCardModal
isOpen={showButtonTileModal}
onClose={() => push(urls.dashboard(dashboard.id))}
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/scenes/dashboard/SectionHeaderModal.tsx
Original file line number Diff line number Diff line change
@@ -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<QueryBasedInsightModel>
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 (
<LemonModal
isOpen={isOpen}
title={resolvedTileId === 'new' ? 'Add section header' : 'Edit section header'}
onClose={handleClose}
width={480}
footer={
<>
<LemonButton
disabledReason={isSectionHeaderSubmitting ? 'Cannot cancel while saving' : null}
type="secondary"
onClick={handleClose}
>
Cancel
</LemonButton>
<LemonButton
disabledReason={firstError as string | null}
loading={isSectionHeaderSubmitting}
form="section-header-form"
htmlType="submit"
type="primary"
data-attr={resolvedTileId === 'new' ? 'save-new-section-header' : 'edit-section-header'}
>
Save
</LemonButton>
</>
}
>
<Form
logic={sectionHeaderModalLogic}
props={logicProps}
formKey="sectionHeader"
id="section-header-form"
enableFormOnSubmit
>
<div className="flex flex-col gap-4">
<Field name="title" label="Title">
<LemonInput placeholder="Acquisition" autoFocus data-attr="section-header-title" />
</Field>
<Field name="description" label="Description (optional)">
<LemonTextArea
placeholder="Explain what this section covers"
minRows={2}
maxRows={4}
data-attr="section-header-description"
/>
</Field>
</div>
</Form>
</LemonModal>
)
}
Original file line number Diff line number Diff line change
@@ -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<QueryBasedInsightModel>

describe('DashboardSectionHeaderItem', () => {
beforeEach(() => {
initKeaTests()
})

it('renders user text literally without editing controls on public dashboards', () => {
const { container, getByText, queryByLabelText } = render(
<DashboardSectionHeaderItem
tile={tile}
sectionHeader={{ title: '<img src=x>', description: '**Literal description**' }}
placement={DashboardPlacement.Public}
onEdit={jest.fn()}
onDuplicate={jest.fn()}
/>
)

expect(getByText('<img src=x>')).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(
<DashboardSectionHeaderItem
tile={tile}
sectionHeader={{ title: 'Acquisition', description: '' }}
placement={DashboardPlacement.Dashboard}
dashboardId={123}
onEdit={jest.fn()}
onDuplicate={jest.fn()}
onRemove={jest.fn()}
/>
)

fireEvent.click(getByLabelText('more'))
expect(getByText('Edit section header')).toBeInTheDocument()
expect(getByText('Duplicate')).toBeInTheDocument()
expect(getByText('Delete')).toBeInTheDocument()
})
})
Loading
Loading