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
34 changes: 34 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,37 @@
max-width: 100%;
}
}

.TextCard--section-header {
background: transparent;
border-bottom: 1px solid var(--color-border-primary);
border-radius: 0;
box-shadow: none;

.TextCard__section-header-body {
display: flex;
align-items: flex-end;
min-height: 100%;
padding: 0.25rem 0 0.875rem;
overflow: hidden;
}

h2 {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
line-height: 1.75rem;
letter-spacing: -0.02em;
}

p {
display: -webkit-box;
margin: 0.375rem 0 0;
overflow: hidden;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
25 changes: 25 additions & 0 deletions frontend/src/lib/components/Cards/TextCard/TextCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fireEvent, render } from '@testing-library/react'
import { DashboardPlacement, DashboardTile, QueryBasedInsightModel } from '~/types'

import { TextCard, TextContent } from './TextCard'
import { buildDashboardSectionHeaderBody } from './textCardSectionHeader'

const makeTextTile = (
overrides: Partial<DashboardTile<QueryBasedInsightModel>> = {}
Expand Down Expand Up @@ -54,6 +55,30 @@ describe('TextCard', () => {
expect(onEnterEditModeFromEdge).toHaveBeenCalledTimes(1)
})

it('renders section headers as plain text, not arbitrary markdown', () => {
const { container, getByText, queryByText } = render(
<TextCard
textTile={makeTextTile({
transparent_background: true,
text: {
body: buildDashboardSectionHeaderBody({
title: 'Activation **funnel**',
description: 'Signup <script>alert(1)</script>',
}),
last_modified_at: '2022-04-01T12:24:36',
},
})}
placement={DashboardPlacement.Dashboard}
moreButtonOverlay={<div>more overlay</div>}
/>
)

expect(container.querySelector('[data-card-kind="section-header"]')).toBeInTheDocument()
expect(getByText('Activation **funnel**')).toBeInTheDocument()
expect(getByText('Signup <script>alert(1)</script>')).toBeInTheDocument()
expect(queryByText('posthog-dashboard-section-header')).not.toBeInTheDocument()
})

describe('TextContent', () => {
it('calls closeDetails when clicked', () => {
const closeDetails = jest.fn()
Expand Down
38 changes: 30 additions & 8 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 { isDashboardSectionHeaderTile, parseDashboardSectionHeaderBody } from './textCardSectionHeader'

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

const isTransparent = textTile.transparent_background
const isSectionHeader = isDashboardSectionHeaderTile(textTile)
const sectionHeader = isSectionHeader ? parseDashboardSectionHeaderBody(text.body) : null

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',
!isTransparent && !isSectionHeader && 'bg-surface-primary border',
!isSectionHeader && isTransparent && showResizeHandles && 'border border-dashed border-border',
isSectionHeader && showResizeHandles && 'border border-dashed border-border',
className
)}
data-attr="text-card"
data-card-kind={isSectionHeader ? 'section-header' : 'text-card'}
{...divProps}
ref={ref}
>
Expand All @@ -114,12 +120,28 @@ function TextCardInternal(
</div>
)}

<div
className={clsx('TextCard__body w-full', onDragHandleMouseDown && 'cursor-grab')}
onMouseDown={onDragHandleMouseDown}
>
<TextContent text={text.body} className={shouldHideMoreButton ? 'p-4' : 'p-4 pr-14'} />
</div>
{sectionHeader ? (
<div
className={clsx(
'TextCard__body TextCard__section-header-body w-full',
onDragHandleMouseDown && 'cursor-grab',
shouldHideMoreButton ? 'pr-0' : 'pr-14'
)}
onMouseDown={onDragHandleMouseDown}
>
<div className="min-w-0 flex-1">
<h2>{sectionHeader.title}</h2>
{sectionHeader.description ? <p>{sectionHeader.description}</p> : null}
</div>
</div>
) : (
<div
className={clsx('TextCard__body w-full', onDragHandleMouseDown && 'cursor-grab')}
onMouseDown={onDragHandleMouseDown}
>
<TextContent text={text.body} className={shouldHideMoreButton ? 'p-4' : 'p-4 pr-14'} />
</div>
)}

{canEnterEditModeFromEdge && !showResizeHandles && onEnterEditModeFromEdge && (
<EditModeEdgeOverlay onEnterEditMode={onEnterEditModeFromEdge} />
Expand Down
132 changes: 132 additions & 0 deletions frontend/src/lib/components/Cards/TextCard/TextCardModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import '@testing-library/jest-dom'

import { cleanup, render } from '@testing-library/react'

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

import { TextCardModal } from './TextCardModal'
import { buildDashboardSectionHeaderBody } from './textCardSectionHeader'

let mockValues: any
const mockResetTextTile = jest.fn()

jest.mock('kea', () => ({
useActions: jest.fn(() => ({ resetTextTile: mockResetTextTile })),
useValues: jest.fn(() => mockValues),
}))

jest.mock('kea-forms', () => ({
Field: ({ children, name }: any) =>
children({
onChange: jest.fn(),
value: mockValues?.textTile?.[name] ?? '',
}),
Form: ({ children }: any) => <form id="text-tile-form">{children}</form>,
}))

jest.mock('lib/components/Cards/TextCard/textCardModalLogic', () => ({
textCardModalLogic: jest.fn(() => ({ __mock: 'textCardModalLogic' })),
}))

jest.mock('lib/components/Cards/TextCard/TextCardModalBodyField', () => ({
TextCardModalBodyField: () => <textarea data-attr="text-card-body-field" />,
}))

jest.mock('lib/lemon-ui/LemonButton', () => ({
LemonButton: ({ children, disabledReason: _disabledReason, htmlType, loading: _loading, type, ...props }: any) => (
<button type={htmlType || (type === 'primary' || type === 'secondary' ? 'button' : type)} {...props}>
{children}
</button>
),
}))

jest.mock('lib/lemon-ui/LemonInput', () => ({
LemonInput: ({ value, onChange, ...props }: any) => (
<input value={value} onChange={(event) => onChange?.(event.target.value)} {...props} />
),
}))

jest.mock('lib/lemon-ui/LemonSwitch', () => ({
LemonSwitch: ({ checked, label: _label, onChange, ...props }: any) => (
<input checked={checked} onChange={(event) => onChange?.(event.target.checked)} type="checkbox" {...props} />
),
}))

jest.mock('lib/lemon-ui/LemonTextArea', () => ({
LemonTextArea: ({ maxRows: _maxRows, minRows: _minRows, onChange, value, ...props }: any) => (
<textarea value={value} onChange={(event) => onChange?.(event.target.value)} {...props} />
),
}))

jest.mock('lib/ui/DialogPrimitive/DialogPrimitive', () => ({
DialogClose: () => <button type="button">Close</button>,
DialogPrimitive: ({ children, disablePointerDismissal }: any) => (
<div data-attr="dialog" data-disable-pointer-dismissal={String(disablePointerDismissal)}>
{children}
</div>
),
DialogPrimitiveTitle: ({ children }: any) => <div>{children}</div>,
}))

const makeDashboard = (body: string): DashboardType<QueryBasedInsightModel> =>
({
id: 1,
tiles: [
{
id: 2,
text: { body },
layouts: {},
transparent_background: true,
},
],
}) as DashboardType<QueryBasedInsightModel>

describe('TextCardModal', () => {
afterEach(() => {
cleanup()
})

beforeEach(() => {
mockValues = {
isTextTileSubmitting: false,
textTile: { body: '', description: '', title: '', transparent_background: false },
textTileValidationErrors: { body: null, description: null, title: null },
}
})

it('recomputes dirty-state baseline when opening an existing text tile after a closed mount', () => {
const dashboard = makeDashboard('Existing text')
const { getByTestId, rerender } = render(
<TextCardModal dashboard={dashboard} isOpen={false} onClose={jest.fn()} textTileId={null} />
)

mockValues = {
...mockValues,
textTile: { body: 'Existing text', description: '', title: '', transparent_background: true },
}
rerender(<TextCardModal dashboard={dashboard} isOpen={true} onClose={jest.fn()} textTileId={2} />)

expect(getByTestId('dialog')).toHaveAttribute('data-disable-pointer-dismissal', 'false')
})

it('recomputes dirty-state baseline when opening an existing section header after a closed mount', () => {
const body = buildDashboardSectionHeaderBody({
description: 'Key funnel steps',
title: 'Activation',
})
const dashboard = makeDashboard(body)
const { getByTestId, rerender } = render(
<TextCardModal dashboard={dashboard} isOpen={false} kind="section" onClose={jest.fn()} textTileId={null} />
)

mockValues = {
...mockValues,
textTile: { body, description: 'Key funnel steps', title: 'Activation', transparent_background: true },
}
rerender(
<TextCardModal dashboard={dashboard} isOpen={true} kind="section" onClose={jest.fn()} textTileId={2} />
)

expect(getByTestId('dialog')).toHaveAttribute('data-disable-pointer-dismissal', 'false')
})
})
Loading