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
32 changes: 32 additions & 0 deletions frontend/src/lib/components/Cards/TextCard/TextCard.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,35 @@
max-width: 100%;
}
}

.TextCard--section-header {
justify-content: center;
border-radius: 0;
border-bottom: 1px solid var(--color-border-primary);

.TextCard__body {
display: flex;
align-items: center;
overflow: hidden;
}

.RichMarkdownEditor,
.LemonMarkdown {
overflow: hidden;
}

.RichMarkdownEditor__content {
h1,
h2,
h3,
p {
margin: 0;
}

h1 {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.75rem;
}
}
}
14 changes: 14 additions & 0 deletions frontend/src/lib/components/Cards/TextCard/TextCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ describe('TextCard', () => {
expect(onEnterEditModeFromEdge).toHaveBeenCalledTimes(1)
})

it('renders section headers as polished full-width rows', () => {
const { container } = render(
<TextCard
textTile={makeTextTile({
text: { body: '# Activation', last_modified_at: '2022-04-01T12:24:36' },
transparent_background: true,
})}
placement={DashboardPlacement.Dashboard}
/>
)

expect(container.querySelector('[data-attr="section-header-card"]')).toHaveClass('TextCard--section-header')
})

describe('TextContent', () => {
it('calls closeDetails when clicked', () => {
const closeDetails = jest.fn()
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/lib/components/Cards/TextCard/TextCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown'
import { DashboardPlacement, DashboardTile, QueryBasedInsightModel } from '~/types'

import { markdownToTextCardDoc, TEXT_CARD_MARKDOWN_READONLY_EXTENSIONS } from './textCardMarkdown'
import { isDashboardSectionHeaderTextTile } from './textCardUtils'

interface TextCardProps extends React.HTMLAttributes<HTMLDivElement>, Resizeable {
textTile: DashboardTile<QueryBasedInsightModel>
Expand Down Expand Up @@ -95,21 +96,23 @@ function TextCardInternal(
const shouldHideMoreButton = placement === DashboardPlacement.Public || showEditingControls === false

const isTransparent = textTile.transparent_background
const isSectionHeader = isDashboardSectionHeaderTextTile(textTile)

return (
<div
className={clsx(
'DashboardTileCard TextCard rounded flex flex-col',
!isTransparent && 'bg-surface-primary border',
isTransparent && showResizeHandles && 'border border-dashed border-border',
isSectionHeader && 'TextCard--section-header',
className
)}
data-attr="text-card"
data-attr={isSectionHeader ? 'section-header-card' : 'text-card'}
{...divProps}
ref={ref}
>
{moreButtonOverlay && !shouldHideMoreButton && (
<div className="absolute right-4 top-4">
<div className={clsx('absolute', isSectionHeader ? 'right-1 top-1' : 'right-4 top-4')}>
<More overlay={moreButtonOverlay} />
</div>
)}
Expand All @@ -118,7 +121,18 @@ function TextCardInternal(
className={clsx('TextCard__body w-full', onDragHandleMouseDown && 'cursor-grab')}
onMouseDown={onDragHandleMouseDown}
>
<TextContent text={text.body} className={shouldHideMoreButton ? 'p-4' : 'p-4 pr-14'} />
<TextContent
text={text.body}
className={
isSectionHeader
? shouldHideMoreButton
? 'px-1 py-2'
: 'py-2 pl-1 pr-10'
: shouldHideMoreButton
? 'p-4'
: 'p-4 pr-14'
}
/>
</div>

{canEnterEditModeFromEdge && !showResizeHandles && onEnterEditModeFromEdge && (
Expand Down
118 changes: 92 additions & 26 deletions frontend/src/lib/components/Cards/TextCard/TextCardModal.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,72 @@
import { useActions, useValues } from 'kea'
import { Field, Form } from 'kea-forms'
import { useCallback, useState } from 'react'
import { useCallback, useMemo } from 'react'

import { isTextCardMarkdownRoundTripSafe } from 'lib/components/Cards/TextCard/textCardMarkdown'
import { TextCardModalBodyField } from 'lib/components/Cards/TextCard/TextCardModalBodyField'
import { textCardModalLogic } from 'lib/components/Cards/TextCard/textCardModalLogic'
import {
DashboardTextTileKind,
DEFAULT_DASHBOARD_SECTION_HEADER_TITLE,
getDashboardSectionHeaderTitle,
getDashboardTextTileKind,
} from 'lib/components/Cards/TextCard/textCardUtils'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonInput } from 'lib/lemon-ui/LemonInput'
import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch'
import { DialogClose, DialogPrimitive, DialogPrimitiveTitle } from 'lib/ui/DialogPrimitive/DialogPrimitive'
import { cn } from 'lib/utils/css-classes'

import { DashboardType, QueryBasedInsightModel } from '~/types'
import { DashboardTile, DashboardType, QueryBasedInsightModel } from '~/types'

function getInitialBody(
dashboard: DashboardType<QueryBasedInsightModel>,
textTileId: number | 'new',
textTileKind: DashboardTextTileKind
): string {
if (textTileId === 'new') {
return textTileKind === 'section' ? DEFAULT_DASHBOARD_SECTION_HEADER_TITLE : ''
}

const body = dashboard.tiles?.find((tile) => tile.id === textTileId)?.text?.body || ''
return textTileKind === 'section' ? (getDashboardSectionHeaderTitle(body) ?? body) : body
}

export function TextCardModal({
isOpen,
onClose,
dashboard,
textTileId,
textTileKind = 'text',
defaultLayouts,
}: {
isOpen: boolean
onClose: () => void
dashboard: DashboardType<QueryBasedInsightModel>
textTileId: number | 'new' | null
textTileKind?: DashboardTextTileKind
defaultLayouts?: DashboardTile<QueryBasedInsightModel>['layouts']
}): JSX.Element {
const resolvedTileId = textTileId ?? 'new'
const modalLogicProps = { dashboard, textTileId: resolvedTileId, onClose }
const existingTile = resolvedTileId !== 'new' ? dashboard.tiles?.find((tile) => tile.id === resolvedTileId) : null
const resolvedTextTileKind = getDashboardTextTileKind(existingTile, textTileKind)
const modalLogicProps = {
dashboard,
textTileId: resolvedTileId,
onClose,
textTileKind: resolvedTextTileKind,
defaultLayouts,
}
const modalLogic = textCardModalLogic(modalLogicProps)
// Form `body` + validation drive updates while typing; splitting useValues does not reduce rerenders.
const { isTextTileSubmitting, textTileValidationErrors, textTile } = useValues(modalLogic)
const { resetTextTile } = useActions(modalLogic)
const [initialBody] = useState(() =>
resolvedTileId !== 'new' ? dashboard.tiles?.find((tile) => tile.id === resolvedTileId)?.text?.body || '' : ''
const initialBody = useMemo(
() => getInitialBody(dashboard, resolvedTileId, resolvedTextTileKind),
[dashboard, resolvedTileId, resolvedTextTileKind]
)
const shouldUseLegacyMarkdownEditor = !isTextCardMarkdownRoundTripSafe(initialBody)
const shouldUseLegacyMarkdownEditor =
resolvedTextTileKind === 'text' && !isTextCardMarkdownRoundTripSafe(initialBody)
const hasUnsavedInput = (textTile?.body || '') !== initialBody

const handleClose = useCallback((): void => {
Expand All @@ -55,7 +89,13 @@ export function TextCardModal({
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-primary py-2 pl-4 pr-2">
<DialogPrimitiveTitle className="min-w-0 flex-1 text-base font-semibold">
{resolvedTileId === 'new' ? 'Add text card' : 'Edit text card'}
{resolvedTextTileKind === 'section'
? resolvedTileId === 'new'
? 'Add section header'
: 'Edit section header'
: resolvedTileId === 'new'
? 'Add text card'
: 'Edit text card'}
</DialogPrimitiveTitle>
<DialogClose className="shrink-0" />
</div>
Expand All @@ -70,24 +110,42 @@ export function TextCardModal({
>
<div className="flex flex-col gap-4">
<Field name="body" label="">
{({ value, onChange }) => (
<TextCardModalBodyField
shouldUseLegacyMarkdownEditor={shouldUseLegacyMarkdownEditor}
value={value}
onChange={onChange}
/>
)}
</Field>
<Field name="transparent_background" label="">
{({ value, onChange }) => (
<LemonSwitch
checked={value}
onChange={onChange}
label="Transparent background"
data-attr="text-card-transparent-background"
/>
)}
{({ value, onChange }) =>
resolvedTextTileKind === 'section' ? (
<div className="flex flex-col gap-2">
<LemonInput
value={value}
onChange={onChange}
placeholder="New section"
autoFocus
data-attr="section-header-title"
/>
<p className="m-0 text-sm text-muted">
Section headers are full-width text tiles for grouping dashboard
content. Drag them in edit mode to rearrange your dashboard.
</p>
</div>
) : (
<TextCardModalBodyField
shouldUseLegacyMarkdownEditor={shouldUseLegacyMarkdownEditor}
value={value}
onChange={onChange}
/>
)
}
</Field>
{resolvedTextTileKind === 'text' && (
<Field name="transparent_background" label="">
{({ value, onChange }) => (
<LemonSwitch
checked={value}
onChange={onChange}
label="Transparent background"
data-attr="text-card-transparent-background"
/>
)}
</Field>
)}
</div>
</Form>
</div>
Expand All @@ -105,9 +163,17 @@ export function TextCardModal({
form="text-tile-form"
htmlType="submit"
type="primary"
data-attr={resolvedTileId === 'new' ? 'save-new-text-tile' : 'edit-text-tile-text'}
data-attr={
resolvedTextTileKind === 'section'
? resolvedTileId === 'new'
? 'save-new-section-header'
: 'edit-section-header'
: resolvedTileId === 'new'
? 'save-new-text-tile'
: 'edit-text-tile-text'
}
>
Save
{resolvedTextTileKind === 'section' && resolvedTileId === 'new' ? 'Add section' : 'Save'}
</LemonButton>
</footer>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { expectLogic } from 'kea-test-utils'
import posthog from 'posthog-js'

import { lemonToast } from '@posthog/lemon-ui'

import { useMocks } from '~/mocks/jest'
import { dashboardsModel } from '~/models/dashboardsModel'
import { initKeaTests } from '~/test/init'
import { AccessControlLevel, DashboardType, QueryBasedInsightModel } from '~/types'

Expand Down Expand Up @@ -38,7 +41,9 @@ const makeDashboard = (body: string = 'existing text'): DashboardType<QueryBased
describe('textCardModalLogic', () => {
beforeEach(() => {
initKeaTests()
dashboardsModel.mount()
jest.spyOn(lemonToast, 'error').mockImplementation(jest.fn())
jest.spyOn(posthog, 'capture').mockImplementation(jest.fn())
})

afterEach(() => {
Expand Down Expand Up @@ -116,4 +121,56 @@ describe('textCardModalLogic', () => {

expect(lemonToast.error).toHaveBeenCalledWith('Could not save text: Network error')
})

it('creates section headers as full-width transparent text tiles', async () => {
let dashboardPatchPayload: Partial<DashboardType<QueryBasedInsightModel>> | null = null
useMocks({
patch: {
'/api/environments/:team_id/dashboards/:id/': async (req) => {
dashboardPatchPayload = await req.json()
return [200, { ...makeDashboard('valid'), ...dashboardPatchPayload }]
},
},
})
const logic = textCardModalLogic({
dashboard: makeDashboard('valid'),
textTileId: 'new',
textTileKind: 'section',
defaultLayouts: { sm: { x: 0, y: 4, w: 12, h: 1 }, xs: { x: 0, y: 2, w: 1, h: 1 } },
onClose: jest.fn(),
})
logic.mount()

await expectLogic(logic, () => {
logic.actions.setTextTileValue('body', 'Activation')
logic.actions.submitTextTile()
}).toFinishAllListeners()

expect(dashboardPatchPayload).toEqual({
tiles: [
{
text: { body: '# Activation' },
transparent_background: true,
layouts: { sm: { x: 0, y: 4, w: 12, h: 1 }, xs: { x: 0, y: 2, w: 1, h: 1 } },
},
],
})
})

it('records when a section header is added', () => {
const logic = textCardModalLogic({
dashboard: makeDashboard('valid'),
textTileId: 'new',
textTileKind: 'section',
onClose: jest.fn(),
})
logic.mount()

logic.actions.submitTextTileSuccess({ body: 'Activation', transparent_background: true })

expect(posthog.capture).toHaveBeenCalledWith('dashboard section header added', {
dashboard_id: 123,
title_length: 10,
})
})
})
Loading