0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

Type LineGraph.jsx and deprecate Posthog/Chart.js dependency (#7722)

This commit is contained in:
Alex Gyujin Kim 2021-12-16 09:36:05 -08:00 committed by GitHub
parent 9c60029818
commit 099cbd0fc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1007 additions and 252 deletions

View File

@ -15,3 +15,10 @@ declare module '*.mp3' {
const content: any
export default content
}
// This fixes TS errors when importing chartjs-plugin-crosshair
declare module 'chartjs-plugin-crosshair' {
const CrosshairPlugin: any
type CrosshairOptions = any
export { CrosshairPlugin, CrosshairOptions }
}

View File

@ -31,12 +31,12 @@ function coordinateContains(e: MouseEvent, element: DOMRect): boolean {
}
interface AnnotationMarkerProps {
elementId: string
elementId?: string
label: string
annotations: AnnotationType[]
annotations?: AnnotationType[]
left: number
top: number
onCreate: (textInput: string, applyAll: boolean) => void
onCreate?: (textInput: string, applyAll: boolean) => void
onDelete?: (annotation: AnnotationType) => void
onClick?: () => void
onClose?: () => void
@ -45,17 +45,17 @@ interface AnnotationMarkerProps {
color: string | null
accessoryColor: string | null
insightId?: number
currentDateMarker: string
currentDateMarker?: string | null
dynamic?: boolean
graphColor: string | null
index: number
index?: number
getPopupContainer?: () => HTMLElement
}
export function AnnotationMarker({
elementId,
label,
annotations,
annotations = [],
left,
top,
onCreate,
@ -95,8 +95,7 @@ export function AnnotationMarker({
const { user } = useValues(userLogic)
const { currentTeam } = useValues(teamLogic)
const { currentOrganization } = useValues(organizationLogic)
const { diffType, groupedAnnotations } = useValues(annotationsLogic({ insightId: insightId }))
const { diffType, groupedAnnotations } = useValues(annotationsLogic({ insightId }))
function closePopup(): void {
setFocused(false)
@ -171,8 +170,8 @@ export function AnnotationMarker({
<Button
type="primary"
onClick={() => {
onCreateAnnotation && onCreateAnnotation(textInput, applyAll)
closePopup()
onCreateAnnotation?.(textInput, applyAll)
setTextInput('')
}}
>
@ -251,7 +250,7 @@ export function AnnotationMarker({
<Button
type="primary"
onClick={() => {
onCreate(textInput, applyAll)
onCreate && onCreate(textInput, applyAll)
setTextInput('')
setTextAreaVisible(false)
}}
@ -319,7 +318,7 @@ export function AnnotationMarker({
onMouseOver={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{annotations ? (
{(annotations?.length || 0) > 0 ? (
<span
style={{
color: focused || hovered || elementId === currentDateMarker ? _accessoryColor : _color,

View File

@ -14,7 +14,7 @@ interface AnnotationsProps {
color: string | null
graphColor: string
accessoryColor: string | null
currentDateMarker: string
currentDateMarker?: string | null
onClick: () => void
onClose: () => void
}
@ -31,31 +31,34 @@ export function Annotations({
onClose,
graphColor,
currentDateMarker,
}: AnnotationsProps): JSX.Element[] {
const { diffType, groupedAnnotations } = useValues(annotationsLogic({ insightId }))
}: AnnotationsProps): JSX.Element {
const logic = annotationsLogic({ insightId })
const { diffType, groupedAnnotations } = useValues(logic)
const { createAnnotation, deleteAnnotation, deleteGlobalAnnotation, createGlobalAnnotation } = useActions(logic)
const { createAnnotation, createAnnotationNow, deleteAnnotation, deleteGlobalAnnotation, createGlobalAnnotation } =
useActions(annotationsLogic({ insightId }))
const onCreate =
(date: string) =>
(input: string, applyAll: boolean): void => {
if (applyAll) {
createGlobalAnnotation(input, date, insightId)
} else {
createAnnotation(input, date)
}
}
const markers: JSX.Element[] = []
const makeAnnotationMarker = (index: number, date: string, annotationsToMark: AnnotationType[]): JSX.Element => (
<AnnotationMarker
insightId={insightId}
elementId={date}
label={dayjs(date).format('MMMM Do YYYY')}
key={index}
left={index * interval + leftExtent - 12.5}
top={topExtent}
annotations={annotationsToMark}
onCreate={(input: string, applyAll: boolean) => {
if (applyAll) {
createGlobalAnnotation(input, date, insightId)
} else if (insightId) {
createAnnotationNow(input, date)
} else {
createAnnotation(input, date)
}
}}
onCreate={onCreate(date)}
onCreateAnnotation={onCreate(date)}
onDelete={(data: AnnotationType) => {
annotationsToMark.length === 1 && onClose?.()
if (data.scope !== AnnotationScope.Insight) {
@ -98,5 +101,5 @@ export function Annotations({
markers.push(makeAnnotationMarker(index, dates[index], annotations))
}
})
return markers
return <>{markers}</>
}

View File

@ -7,7 +7,6 @@ import { getNextKey } from './utils'
import { annotationsLogicType } from './annotationsLogicType'
import { AnnotationScope, AnnotationType } from '~/types'
import { teamLogic } from 'scenes/teamLogic'
import { userLogic } from 'scenes/userLogic'
interface AnnotationsLogicProps {
insightId?: number
@ -23,23 +22,12 @@ export const annotationsLogic = kea<annotationsLogicType<AnnotationsLogicProps>>
},
actions: () => ({
createAnnotation: (content: string, date_marker: string, scope: AnnotationScope = AnnotationScope.Insight) => ({
content,
date_marker,
created_at: dayjs(),
scope,
}),
createAnnotationNow: (
content: string,
date_marker: string,
scope: AnnotationScope = AnnotationScope.Insight
) => ({
content,
date_marker,
created_at: dayjs() as Dayjs,
scope,
}),
deleteAnnotation: (id: string) => ({ id }),
clearAnnotationsToCreate: true,
updateDiffType: (dates: string[]) => ({ dates }),
setDiffType: (type: OpUnitType) => ({ type }),
}),
@ -47,6 +35,10 @@ export const annotationsLogic = kea<annotationsLogicType<AnnotationsLogicProps>>
annotations: {
__default: [] as AnnotationType[],
loadAnnotations: async () => {
if (!props.insightId) {
throw new Error('Can only load annotations for insight whose id is known.')
}
const params = {
...(props.insightId ? { dashboardItemId: props.insightId } : {}),
scope: AnnotationScope.Insight,
@ -61,7 +53,7 @@ export const annotationsLogic = kea<annotationsLogicType<AnnotationsLogicProps>>
}),
reducers: {
annotations: {
createAnnotationNow: (state, { content, date_marker, created_at, scope }) => [
createAnnotation: (state, { content, date_marker, created_at, scope }) => [
...state,
{
id: getNextKey(state).toString(),
@ -69,7 +61,6 @@ export const annotationsLogic = kea<annotationsLogicType<AnnotationsLogicProps>>
date_marker: date_marker,
created_at: created_at.toISOString(),
updated_at: created_at.toISOString(),
created_by: userLogic.values.user,
scope,
},
],
@ -81,32 +72,6 @@ export const annotationsLogic = kea<annotationsLogicType<AnnotationsLogicProps>>
}
},
},
annotationsToCreate: [
[] as AnnotationType[],
{
createAnnotation: (state, { content, date_marker, created_at, scope }) => [
...state,
{
id: getNextKey(state).toString(),
content,
date_marker: date_marker,
created_at: created_at.toISOString(),
updated_at: created_at.toISOString(),
created_by: userLogic.values.user,
scope,
},
],
clearAnnotationsToCreate: () => [],
deleteAnnotation: (state, { id }) => {
if (parseInt(id) < 0) {
return state.filter((a) => a.id !== id)
} else {
return state
}
},
},
],
diffType: [
'day' as string,
{
@ -116,9 +81,8 @@ export const annotationsLogic = kea<annotationsLogicType<AnnotationsLogicProps>>
},
selectors: ({ selectors }) => ({
annotationsList: [
() => [selectors.annotationsToCreate, selectors.annotations, selectors.activeGlobalAnnotations],
(annotationsToCreate, annotations, activeGlobalAnnotations) =>
[...annotationsToCreate, ...annotations, ...activeGlobalAnnotations] as AnnotationType[],
() => [selectors.annotations, selectors.activeGlobalAnnotations],
(annotations, activeGlobalAnnotations): AnnotationType[] => [...annotations, ...activeGlobalAnnotations],
],
groupedAnnotations: [
() => [selectors.annotationsList, selectors.diffType],
@ -131,7 +95,11 @@ export const annotationsLogic = kea<annotationsLogicType<AnnotationsLogicProps>>
],
}),
listeners: ({ actions, props }) => ({
createAnnotationNow: async ({ content, date_marker, created_at, scope }) => {
createAnnotation: async ({ content, date_marker, created_at, scope }) => {
if (!props.insightId) {
throw new Error('Can only create annotations for insights whose id is known.')
}
await api.create(`api/projects/${teamLogic.values.currentTeamId}/annotations`, {
content,
date_marker: dayjs(date_marker).toISOString(),
@ -153,7 +121,7 @@ export const annotationsLogic = kea<annotationsLogicType<AnnotationsLogicProps>>
actions.setDiffType(determineDifferenceType(dates[0], dates[1]))
},
}),
events: ({ actions, props }) => ({
afterMount: () => props.insightId && actions.loadAnnotations(),
events: ({ actions }) => ({
afterMount: () => actions.loadAnnotations(),
}),
})

View File

@ -16,7 +16,7 @@ import { ANTD_TOOLTIP_PLACEMENTS } from 'lib/utils'
interface ChartFilterProps {
filters: FilterType
onChange: (chartFilter: ChartDisplayType | FunnelVizType) => void
onChange?: (chartFilter: ChartDisplayType | FunnelVizType) => void
disabled: boolean
}
@ -130,7 +130,7 @@ export function ChartFilter({ filters, onChange, disabled }: ChartFilterProps):
value={chartFilter || defaultDisplay}
onChange={(value: ChartDisplayType | FunnelVizType) => {
setChartFilter(value)
onChange(value)
onChange?.(value)
}}
bordered
dropdownAlign={ANTD_TOOLTIP_PLACEMENTS.bottomRight}

View File

@ -66,7 +66,7 @@ export interface LemonTableProps<T extends Record<string, any>> {
export function LemonTable<T extends Record<string, any>>({
id,
columns,
dataSource,
dataSource = [],
rowKey,
rowClassName,
onRow,

View File

@ -83,6 +83,7 @@ export const FEATURE_FLAGS = {
RETENTION_BREAKDOWN: 'retention-breakdown', // owner: @hazzadous
STALE_EVENTS: 'stale-events', // owner: @paolodamico
INSIGHT_LEGENDS: 'insight-legends', // owner: @alexkim205
LINE_GRAPH_V2: 'line-graph-v2', // owner @alexkim205
DASHBOARD_REDESIGN: 'dashboard-redesign', // owner: @Twixes
}

View File

@ -1276,3 +1276,7 @@ export function isEllipsisActive(e: HTMLElement | null): boolean {
export function isGroupType(actor: ActorType): actor is GroupActorType {
return actor.type === 'group'
}
export function mapRange(value: number, x1: number, y1: number, x2: number, y2: number): number {
return Math.floor(((value - x1) * (y2 - x2)) / (y1 - x1) + x2)
}

View File

@ -27,7 +27,7 @@ import {
InsightLogicProps,
InsightShortId,
} from '~/types'
import { ActionsBarValueGraph } from 'scenes/trends/viz'
import { ActionsHorizontalBar } from 'scenes/trends/viz'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { Funnel } from 'scenes/funnels/Funnel'
import { Tooltip } from 'lib/components/Tooltip'
@ -100,7 +100,7 @@ export const displayMap: Record<DisplayedType, DisplayProps> = {
},
ActionsBarValue: {
className: 'bar',
element: ActionsBarValueGraph,
element: ActionsHorizontalBar,
viewText: 'View graph',
},
ActionsTable: {

View File

@ -1,18 +1,19 @@
import React from 'react'
import { LineGraph } from 'scenes/insights/LineGraph'
import { LineGraph } from 'scenes/insights/LineGraph/LineGraph'
import { funnelLogic } from 'scenes/funnels/funnelLogic'
import { useActions, useValues } from 'kea'
import { personsModalLogic } from 'scenes/trends/personsModalLogic'
import { ChartParams } from '~/types'
import { ChartParams, GraphType, GraphDataset } from '~/types'
import { insightLogic } from 'scenes/insights/insightLogic'
import { capitalizeFirstLetter } from 'lib/utils'
import { dayjs } from 'lib/dayjs'
export function FunnelLineGraph({
dashboardItemId,
inSharedMode,
color = 'white',
}: Omit<ChartParams, 'filters'>): JSX.Element | null {
const { insightProps } = useValues(insightLogic)
const { insightProps, insight } = useValues(insightLogic)
const logic = funnelLogic(insightProps)
const { steps, filters, aggregationTargetLabel, incompletenessOffsetFromEnd } = useValues(logic)
const { loadPeople } = useActions(personsModalLogic)
@ -20,29 +21,36 @@ export function FunnelLineGraph({
return (
<LineGraph
data-attr="trend-line-graph-funnel"
type="line"
type={GraphType.Line}
color={color}
datasets={steps}
datasets={steps as unknown as GraphDataset[] /* TODO: better typing */}
labels={steps?.[0]?.labels ?? ([] as string[])}
isInProgress={incompletenessOffsetFromEnd < 0}
dashboardItemId={dashboardItemId}
inSharedMode={inSharedMode}
insightId={insight.id}
inSharedMode={!!inSharedMode}
percentage={true}
incompletenessOffsetFromEnd={incompletenessOffsetFromEnd}
onClick={
dashboardItemId
? null
: (point) => {
? undefined
: (payload) => {
const { points, index } = payload
const dataset = points.clickedPointNotLine
? points.pointsIntersectingClick[0].dataset
: points.pointsIntersectingLine[0].dataset
const day = dataset?.days?.[index] ?? ''
const label = dataset?.label ?? dataset?.labels?.[index] ?? ''
loadPeople({
action: { id: point.index, name: point.label, properties: [], type: 'actions' },
label: `${capitalizeFirstLetter(aggregationTargetLabel.plural)} converted on ${
point.label
}`,
date_from: point.day,
date_to: point.day,
action: { id: index, name: label ?? null, properties: [], type: 'actions' },
label: `${capitalizeFirstLetter(aggregationTargetLabel.plural)} converted on ${dayjs(
label
).format('MMMM Do YYYY')}`,
date_from: day ?? '',
date_to: day ?? '',
filters: filters,
saveOriginal: true,
pointValue: point.value,
pointValue: dataset?.data?.[index] ?? undefined,
})
}
}

View File

@ -10,12 +10,11 @@ import { Paths } from 'scenes/paths/Paths'
import { ACTIONS_BAR_CHART_VALUE, ACTIONS_TABLE, FEATURE_FLAGS, FUNNEL_VIZ, FunnelLayout } from 'lib/constants'
import { People } from 'scenes/funnels/FunnelPeople'
import { FunnelStepTable } from 'scenes/insights/InsightTabs/FunnelTab/FunnelStepTable'
import { BindLogic, useActions, useValues } from 'kea'
import { BindLogic, useValues } from 'kea'
import { trendsLogic } from 'scenes/trends/trendsLogic'
import { InsightsTable } from 'scenes/insights/InsightsTable'
import React from 'react'
import { insightLogic } from 'scenes/insights/insightLogic'
import { annotationsLogic } from 'lib/components/Annotations'
import {
FunnelInvalidExclusionState,
FunnelSingleStepState,
@ -46,7 +45,6 @@ export function InsightContainer({ disableTable }: { disableTable?: boolean } =
const { preflight } = useValues(preflightLogic)
const { featureFlags } = useValues(featureFlagLogic)
const {
insight,
insightProps,
lastRefresh,
isLoading,
@ -60,8 +58,6 @@ export function InsightContainer({ disableTable }: { disableTable?: boolean } =
const { areFiltersValid, isValidFunnel, areExclusionFiltersValid, correlationAnalysisAvailable } = useValues(
funnelLogic(insightProps)
)
const { clearAnnotationsToCreate } = useActions(annotationsLogic({ insightId: insight.id }))
const { annotationsToCreate } = useValues(annotationsLogic({ insightId: insight.id }))
// Empty states that completely replace the graph
const BlockingEmptyState = (() => {
@ -164,8 +160,6 @@ export function InsightContainer({ disableTable }: { disableTable?: boolean } =
insightMode={insightMode}
filters={filters}
disableTable={!!disableTable}
annotationsToCreate={annotationsToCreate}
clearAnnotationsToCreate={clearAnnotationsToCreate}
/>
}
data-attr="insights-graph"

View File

@ -1,28 +1,26 @@
import { CalendarOutlined } from '@ant-design/icons'
import React from 'react'
import { useValues } from 'kea'
import { ChartFilter } from 'lib/components/ChartFilter'
import { CompareFilter } from 'lib/components/CompareFilter/CompareFilter'
import { IntervalFilter } from 'lib/components/IntervalFilter'
import { ACTIONS_BAR_CHART_VALUE, ACTIONS_PIE_CHART, ACTIONS_TABLE, FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import React from 'react'
import { insightLogic } from 'scenes/insights/insightLogic'
import { FunnelBinsPicker } from 'scenes/insights/InsightTabs/FunnelTab/FunnelBinsPicker'
import { ChartDisplayType, FilterType, FunnelVizType, InsightType, ItemMode } from '~/types'
import { FilterType, FunnelVizType, ItemMode, InsightType } from '~/types'
import { CalendarOutlined } from '@ant-design/icons'
import { InsightDateFilter } from '../InsightDateFilter'
import { RetentionDatePicker } from '../RetentionDatePicker'
import { FunnelDisplayLayoutPicker } from './FunnelTab/FunnelDisplayLayoutPicker'
import { FunnelStepReferencePicker } from './FunnelTab/FunnelStepReferencePicker'
import { FunnelDisplayLayoutPicker } from './FunnelTab/FunnelDisplayLayoutPicker'
import { FunnelBinsPicker } from 'scenes/insights/InsightTabs/FunnelTab/FunnelBinsPicker'
import { PathStepPicker } from './PathTab/PathStepPicker'
import { ReferencePicker as RetentionReferencePicker } from './RetentionTab/ReferencePicker'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { insightLogic } from 'scenes/insights/insightLogic'
interface InsightDisplayConfigProps {
clearAnnotationsToCreate: () => void
filters: FilterType
activeView: InsightType
insightMode: ItemMode
disableTable: boolean
annotationsToCreate: Record<string, any>[] // TODO: Annotate properly
}
const showIntervalFilter = function (activeView: InsightType, filter: FilterType): boolean {
@ -82,12 +80,7 @@ const isFunnelEmpty = (filters: FilterType): boolean => {
return (!filters.actions && !filters.events) || (filters.actions?.length === 0 && filters.events?.length === 0)
}
export function InsightDisplayConfig({
filters,
activeView,
clearAnnotationsToCreate,
disableTable,
}: InsightDisplayConfigProps): JSX.Element {
export function InsightDisplayConfig({ filters, activeView, disableTable }: InsightDisplayConfigProps): JSX.Element {
const showFunnelBarOptions = activeView === InsightType.FUNNELS
const showPathOptions = activeView === InsightType.PATHS
const dateFilterDisabled = showFunnelBarOptions && isFunnelEmpty(filters)
@ -146,15 +139,7 @@ export function InsightDisplayConfig({
{showChartFilter(activeView) && (
<span className="filter">
<span className="head-title-item">Chart type</span>
<ChartFilter
onChange={(display: ChartDisplayType | FunnelVizType) => {
if (display === ACTIONS_TABLE || display === ACTIONS_PIE_CHART) {
clearAnnotationsToCreate()
}
}}
filters={filters}
disabled={filters.insight === InsightType.LIFECYCLE}
/>
<ChartFilter filters={filters} disabled={filters.insight === InsightType.LIFECYCLE} />
</span>
)}
{showFunnelBarOptions && filters.funnel_viz_type === FunnelVizType.Steps && (

View File

@ -4,9 +4,9 @@ import React from 'react'
import { IntervalType } from '~/types'
import './InsightTooltip.scss'
interface BodyLine {
export interface BodyLine {
id?: string | number
component: JSX.Element
component: React.ReactNode
}
interface InsightTooltipProps {

View File

@ -8,7 +8,6 @@ import PropTypes from 'prop-types'
import { compactNumber, lightenDarkenColor } from '~/lib/utils'
import { getBarColorFromStatus, getChartColors, getGraphColors } from 'lib/colors'
import { useWindowSize } from 'lib/hooks/useWindowSize'
import { toast } from 'react-toastify'
import { Annotations, annotationsLogic, AnnotationMarker } from 'lib/components/Annotations'
import { useEscapeKey } from 'lib/hooks/useEscapeKey'
import './LineGraph.scss'
@ -24,7 +23,7 @@ Chart.defaults.global.elements.line.tension = 0
const noop = () => {}
export function LineGraph({
export function LEGACY_LineGraph({
datasets,
visibilityMap = null,
labels,
@ -33,7 +32,7 @@ export function LineGraph({
isInProgress = false,
onClick,
['data-attr']: dataAttr,
dashboardItemId /* used only for annotations, not to init any other logic */,
insightId,
inSharedMode,
percentage = false,
interval = undefined,
@ -54,12 +53,12 @@ export function LineGraph({
const [labelIndex, setLabelIndex] = useState(null)
const [holdLabelIndex, setHoldLabelIndex] = useState(null)
const [selectedDayLabel, setSelectedDayLabel] = useState(null)
const { createAnnotation, createAnnotationNow, updateDiffType, createGlobalAnnotation } = !inSharedMode
? useActions(annotationsLogic({ pageKey: dashboardItemId || null }))
: { createAnnotation: noop, createAnnotationNow: noop, updateDiffType: noop, createGlobalAnnotation: noop }
const { createAnnotation, updateDiffType, createGlobalAnnotation } = !inSharedMode
? useActions(annotationsLogic({ insightId }))
: { createAnnotation: noop, updateDiffType: noop, createGlobalAnnotation: noop }
const { annotationsList, annotationsLoading } = !inSharedMode
? useValues(annotationsLogic({ pageKey: dashboardItemId || null }))
? useValues(annotationsLogic({ insightId }))
: { annotationsList: [], annotationsLoading: false }
const [leftExtent, setLeftExtent] = useState(0)
const [boundaryInterval, setBoundaryInterval] = useState(0)
@ -145,7 +144,7 @@ export function LineGraph({
const mainColor = dataset?.status ? getBarColorFromStatus(dataset.status) : colorList[index % colorList.length]
const hoverColor = dataset?.status ? getBarColorFromStatus(dataset.status, true) : mainColor
// `horizontalBar` colors are set in `ActionsBarValueGraph.tsx` and overriden in spread of `dataset` below
// `horizontalBar` colors are set in `ActionsHorizontalBar.tsx` and overriden in spread of `dataset` below
const BACKGROUND_BASED_CHARTS = ['bar', 'doughnut']
return {
@ -401,22 +400,14 @@ export function LineGraph({
onClick: (_, [point]) => {
if (point && onClick) {
const dataset = datasets[point._datasetIndex]
// Makes onClick forward compatible with new LineGraph typing
onClick({
point,
dataset,
points: {
pointsIntersectingLine: [{ ...point, dataset }],
pointsIntersectingClick: [{ ...point, dataset }],
clickedPointNotLine: true,
},
index: point._index,
label:
typeof point._index !== 'undefined' && dataset.labels
? dataset.labels[point._index]
: undefined,
day:
typeof point._index !== 'undefined' && dataset.days
? dataset.days[point._index]
: undefined,
value:
typeof point._index !== 'undefined' && dataset.data
? dataset.data[point._index]
: undefined,
})
}
},
@ -558,7 +549,7 @@ export function LineGraph({
leftExtent={leftExtent}
interval={boundaryInterval}
topExtent={topExtent}
dashboardItemId={dashboardItemId}
insightId={insightId}
currentDateMarker={
focused || annotationsFocused ? selectedDayLabel : enabled ? datasets[0].days[labelIndex] : null
}
@ -566,7 +557,9 @@ export function LineGraph({
setFocused(false)
setAnnotationsFocused(true)
}}
onClose={() => setAnnotationsFocused(false)}
onClose={() => {
setAnnotationsFocused(false)
}}
graphColor={color}
color={colors.annotationColor}
accessoryColor={colors.annotationAccessoryColor}
@ -574,7 +567,7 @@ export function LineGraph({
)}
{annotationsCondition && !annotationsFocused && (enabled || focused) && left >= 0 && (
<AnnotationMarker
dashboardItemId={dashboardItemId}
insightId={insightId}
currentDateMarker={focused ? selectedDayLabel : datasets[0].days[labelIndex]}
onClick={() => {
setFocused(true)
@ -584,13 +577,13 @@ export function LineGraph({
}}
getPopupContainer={() => annotationsRoot?.current}
onCreateAnnotation={(textInput, applyAll) => {
if (applyAll) {
createGlobalAnnotation(textInput, datasets[0].days[holdLabelIndex], dashboardItemId)
} else if (dashboardItemId) {
createAnnotationNow(textInput, datasets[0].days[holdLabelIndex])
} else {
createAnnotation(textInput, datasets[0].days[holdLabelIndex])
toast('This annotation will be saved if the graph is made into a dashboard item!')
const date = datasets?.[0]?.days?.[holdLabelIndex]
if (date) {
if (applyAll) {
createGlobalAnnotation(textInput, date, insightId)
} else {
createAnnotation(textInput, date)
}
}
}}
onClose={() => setFocused(false)}
@ -609,7 +602,7 @@ export function LineGraph({
const mapRange = (value, x1, y1, x2, y2) => Math.floor(((value - x1) * (y2 - x2)) / (y1 - x1) + x2)
LineGraph.propTypes = {
LEGACY_LineGraph.propTypes = {
datasets: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string, count: PropTypes.number })).isRequired,
labels: PropTypes.array.isRequired,
options: PropTypes.object,

View File

@ -0,0 +1,720 @@
import React, { useEffect, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { getContext, useActions, useValues } from 'kea'
import {
ActiveElement,
Chart,
ChartDataset,
ChartEvent,
ChartItem,
ChartOptions,
ChartPluginsOptions,
ChartType,
Color,
InteractionItem,
TickOptions,
TooltipItem,
TooltipModel,
TooltipOptions,
} from 'chart.js'
import { CrosshairOptions, CrosshairPlugin } from 'chartjs-plugin-crosshair'
import 'chartjs-adapter-dayjs'
import { compactNumber, lightenDarkenColor, mapRange } from '~/lib/utils'
import { getBarColorFromStatus, getChartColors, getGraphColors } from 'lib/colors'
import { useWindowSize } from 'lib/hooks/useWindowSize'
import { AnnotationMarker, Annotations, annotationsLogic } from 'lib/components/Annotations'
import { useEscapeKey } from 'lib/hooks/useEscapeKey'
import './LineGraph.scss'
import { InsightTooltip } from '../InsightTooltip/InsightTooltip'
import { dayjs } from 'lib/dayjs'
import { AnnotationType, GraphDataset, GraphPointPayload, GraphType, IntervalType } from '~/types'
import { InsightLabel } from 'lib/components/InsightLabel'
import { LEGACY_LineGraph } from './LEGACY_LineGraph.jsx'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { FEATURE_FLAGS } from 'lib/constants'
//--Chart Style Options--//
Chart.register(CrosshairPlugin)
Chart.defaults.animation['duration'] = 0
//--Chart Style Options--//
interface LineGraphProps {
datasets: GraphDataset[]
visibilityMap?: Record<string | number, any>
labels: string[]
color: string
type: GraphType
isInProgress?: boolean
onClick?: (payload: GraphPointPayload) => void
['data-attr']: string
insightId?: number
inSharedMode?: boolean
percentage?: boolean
interval?: IntervalType
totalValue?: number
showPersonsModal?: boolean
tooltipPreferAltTitle?: boolean
isCompare?: boolean
incompletenessOffsetFromEnd?: number // Number of data points at end of dataset to replace with a dotted line. Only used in line graphs.
}
const noop = (): void => {}
export function LineGraph(props: LineGraphProps): JSX.Element {
const { featureFlags } = useValues(featureFlagLogic)
if (!featureFlags[FEATURE_FLAGS.LINE_GRAPH_V2]) {
// @ts-ignore
return <LEGACY_LineGraph {...props} />
}
const {
datasets: _datasets,
visibilityMap,
labels,
color,
type,
isInProgress = false,
onClick,
['data-attr']: dataAttr,
insightId,
inSharedMode = false,
percentage = false,
interval = undefined,
totalValue,
showPersonsModal = true,
tooltipPreferAltTitle = false,
isCompare = false,
incompletenessOffsetFromEnd = -1,
} = props
let datasets = _datasets
const chartRef = useRef<HTMLCanvasElement | null>(null)
const myLineChart = useRef<Chart<ChartType, any, string>>()
const annotationsRoot = useRef<HTMLDivElement | null>(null)
const [left, setLeft] = useState(-1)
const [holdLeft, setHoldLeft] = useState(0)
const [enabled, setEnabled] = useState(false)
const [focused, setFocused] = useState(false)
const [annotationsFocused, setAnnotationsFocused] = useState(false)
const [labelIndex, setLabelIndex] = useState<number | null>(null)
const [holdLabelIndex, setHoldLabelIndex] = useState<number | null>(null)
const [selectedDayLabel, setSelectedDayLabel] = useState<string | null>(null)
const { createAnnotation, updateDiffType, createGlobalAnnotation } = !inSharedMode
? useActions(annotationsLogic({ insightId }))
: { createAnnotation: noop, updateDiffType: noop, createGlobalAnnotation: noop }
const { annotationsList, annotationsLoading } = !inSharedMode
? useValues(annotationsLogic({ insightId }))
: { annotationsList: [], annotationsLoading: false }
const [leftExtent, setLeftExtent] = useState(0)
const [boundaryInterval, setBoundaryInterval] = useState(0)
const [topExtent, setTopExtent] = useState(0)
const [annotationInRange, setInRange] = useState(false)
const size = useWindowSize()
const annotationsCondition =
type === GraphType.Line && datasets?.length > 0 && !inSharedMode && datasets[0].labels?.[0] !== '1 day' // stickiness graphs
const colors = getGraphColors(color === 'white')
const isHorizontal = type === GraphType.HorizontalBar
const isBar = [GraphType.Bar, GraphType.HorizontalBar, GraphType.Histogram].includes(type)
const isBackgroundBasedGraphType = [GraphType.Bar, GraphType.HorizontalBar, GraphType.Pie]
useEscapeKey(() => setFocused(false), [focused])
useEffect(() => {
buildChart()
}, [datasets, color, visibilityMap])
// annotation related effects
// update boundaries and axis padding when user hovers with mouse or annotations load
useEffect(() => {
if (annotationsCondition && myLineChart.current?.options?.scales?.x?.grid) {
myLineChart.current.options.scales.x.grid.tickLength = annotationInRange || focused ? 45 : 10
myLineChart.current.update()
calculateBoundaries()
}
}, [annotationsLoading, annotationsCondition, annotationsList, annotationInRange])
useEffect(() => {
if (annotationsCondition && (datasets?.[0]?.days?.length ?? 0) > 0) {
const begin = dayjs(datasets[0].days?.[0])
const end = dayjs(datasets[0].days?.[datasets[0].days.length - 1]).add(2, 'days')
const checkBetween = (element: AnnotationType): boolean =>
dayjs(element.date_marker).isSameOrBefore(end) && dayjs(element.date_marker).isSameOrAfter(begin)
setInRange(annotationsList.some(checkBetween))
}
}, [datasets, annotationsList, annotationsCondition])
// recalculate diff if interval type selection changes
useEffect(() => {
if (annotationsCondition && datasets?.[0]?.days) {
updateDiffType(datasets[0].days)
}
}, [datasets, type, annotationsCondition])
// update only boundaries when window size changes or chart type changes
useEffect(() => {
if (annotationsCondition) {
calculateBoundaries()
}
}, [myLineChart.current, size, type, annotationsCondition])
function calculateBoundaries(): void {
if (myLineChart.current) {
const boundaryLeftExtent = myLineChart.current.scales.x.left
const boundaryRightExtent = myLineChart.current.scales.x.right
const boundaryTicks = myLineChart.current.scales.x.ticks.length
const boundaryDelta = boundaryRightExtent - boundaryLeftExtent
const _boundaryInterval = boundaryDelta / (boundaryTicks - 1)
const boundaryTopExtent = myLineChart.current.scales.x.top + 8
setLeftExtent(boundaryLeftExtent)
setBoundaryInterval(_boundaryInterval)
setTopExtent(boundaryTopExtent)
}
}
function processDataset(dataset: ChartDataset<any>, index: number): ChartDataset<any> {
const colorList = getChartColors(color || 'white', datasets.length, isCompare)
const mainColor = dataset?.status ? getBarColorFromStatus(dataset.status) : colorList[index % colorList.length]
const hoverColor = dataset?.status ? getBarColorFromStatus(dataset.status, true) : mainColor
// `horizontalBar` colors are set in `ActionsHorizontalBar.tsx` and overriden in spread of `dataset` below
return {
borderColor: mainColor,
hoverBorderColor: isBackgroundBasedGraphType ? lightenDarkenColor(mainColor, -20) : hoverColor,
hoverBackgroundColor: isBackgroundBasedGraphType ? lightenDarkenColor(mainColor, -20) : undefined,
backgroundColor: isBackgroundBasedGraphType ? mainColor : undefined,
fill: false,
borderWidth: isBar ? 0 : 2,
pointRadius: 0,
hitRadius: 0,
...(type === GraphType.Histogram ? { barPercentage: 1 } : {}),
...dataset,
hoverBorderWidth: isBar ? 0 : 2,
hoverBorderRadius: isBar ? 0 : 2,
type: (isHorizontal ? GraphType.Bar : type) as ChartType,
}
}
function buildChart(): void {
const myChartRef = chartRef.current?.getContext('2d')
if (typeof myLineChart.current !== 'undefined') {
myLineChart.current.destroy()
}
// if chart is line graph, make duplicate lines and overlay to show dotted lines
if (type === GraphType.Line) {
datasets = [
...datasets.map((dataset, index) => {
const sliceTo = incompletenessOffsetFromEnd || (dataset.data?.length ?? 0)
const datasetCopy = Object.assign({}, dataset, {
data: [...(dataset.data || [])].slice(0, sliceTo),
labels: [...(dataset.labels || [])].slice(0, sliceTo),
days: [...(dataset.days || [])].slice(0, sliceTo),
})
return processDataset(datasetCopy, index)
}),
...datasets.map((dataset, index) => {
const datasetCopy = Object.assign({}, dataset)
datasetCopy.dotted = true
// if last date is still active show dotted line
if (isInProgress) {
datasetCopy['borderDash'] = [10, 10]
}
// Nullify dates that don't have dotted line
const sliceFrom = incompletenessOffsetFromEnd - 1 || (datasetCopy.data?.length ?? 0)
datasetCopy.data = [
...(datasetCopy.data?.slice(0, sliceFrom).map(() => null) ?? []),
...(datasetCopy.data?.slice(sliceFrom) ?? []),
] as number[]
return processDataset(datasetCopy, index)
}),
]
if (visibilityMap && Object.keys(visibilityMap).length > 0) {
datasets = datasets.filter((data) => visibilityMap[data.id])
}
} else {
datasets = datasets.map((dataset, index) => processDataset(dataset, index))
}
const tickOptions: Partial<TickOptions> = {
color: colors.axisLabel as Color,
}
const tooltipOptions: Partial<TooltipOptions> = {
enabled: false, // disable builtin tooltip (use custom markup)
mode: 'nearest',
// If bar, we want to only show the tooltip for what we're hovering over
// to avoid confusion
axis: isHorizontal ? 'y' : 'x',
intersect: false,
itemSort: (a, b) => a.label.localeCompare(b.label),
}
let options: ChartOptions & { plugins: ChartPluginsOptions } = {
responsive: true,
maintainAspectRatio: false,
scaleShowHorizontalLines: false,
elements: {
line: {
tension: 0,
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
...tooltipOptions,
external(args: { chart: Chart; tooltip: TooltipModel<ChartType> }) {
let tooltipEl = document.getElementById('ph-graph-tooltip')
const { tooltip } = args
// Create element on first render
if (!tooltipEl) {
tooltipEl = document.createElement('div')
tooltipEl.id = 'ph-graph-tooltip'
tooltipEl.classList.add('ph-graph-tooltip')
document.body.appendChild(tooltipEl)
}
if (tooltip.opacity === 0) {
tooltipEl.style.opacity = '0'
return
}
if (!chartRef.current) {
return
}
// Set caret position
// Reference: https://www.chartjs.org/docs/master/configuration/tooltip.html
tooltipEl.classList.remove('above', 'below', 'no-transform')
tooltipEl.classList.add(tooltip.yAlign || 'no-transform')
const bounds = chartRef.current.getBoundingClientRect()
const chartClientLeft = bounds.left + window.pageXOffset
tooltipEl.style.opacity = '1'
tooltipEl.style.position = 'absolute'
tooltipEl.style.padding = '10px'
tooltipEl.style.pointerEvents = 'none'
if (tooltip.body) {
const referenceDataPoint = tooltip.dataPoints[0] // Use this point as reference to get the date
const dataset = datasets[referenceDataPoint.datasetIndex]
const altTitle =
tooltip.title && (dataset.compare || tooltipPreferAltTitle) ? tooltip.title[0] : '' // When comparing we show the whole range for clarity; when on stickiness we show the relative timeframe (e.g. `5 days`)
const referenceDate = !dataset.compare
? dataset.days?.[referenceDataPoint.dataIndex]
: undefined
const bodyLines = tooltip.body
.flatMap(({ lines }) => lines)
.map((component, idx) => ({
id: idx,
component,
}))
ReactDOM.render(
<Provider store={getContext().store}>
<InsightTooltip
altTitle={altTitle}
referenceDate={referenceDate}
interval={interval}
bodyLines={bodyLines}
inspectPersonsLabel={onClick && showPersonsModal}
preferAltTitle={tooltipPreferAltTitle}
hideHeader={isHorizontal}
/>
</Provider>,
tooltipEl
)
}
const horizontalBarTopOffset = isHorizontal ? tooltip.caretY - tooltipEl.clientHeight / 2 : 0
const tooltipClientTop = bounds.top + window.pageYOffset + horizontalBarTopOffset
const defaultOffsetLeft = Math.max(chartClientLeft, chartClientLeft + tooltip.caretX + 8)
const maxXPosition = bounds.right - tooltipEl.clientWidth
const tooltipClientLeft =
defaultOffsetLeft > maxXPosition
? chartClientLeft + tooltip.caretX - tooltipEl.clientWidth - 8 // If tooltip is too large (or close to the edge), show it to the left of the data point instead
: defaultOffsetLeft
tooltipEl.style.top = tooltipClientTop + 'px'
tooltipEl.style.left = tooltipClientLeft + 'px'
},
callbacks: {
// @ts-ignore: label callback is typed to return string | string[], but practically can return ReactNode
label(tooltipItem: TooltipItem<any>) {
const entityData = tooltipItem.dataset
const tooltipDatasets = this.dataPoints.map((point) => point.dataset as ChartDataset<any>)
if (entityData.dotted && !(tooltipItem.dataIndex === entityData.data.length - 1)) {
return ''
}
const label = entityData.chartLabel || entityData.label || tooltipItem.label || ''
const action =
entityData.action || (entityData.actions && entityData.actions[tooltipItem.dataIndex])
let value = tooltipItem.formattedValue.toLocaleString()
const actionObjKey = isHorizontal ? 'actions' : 'action'
if (isHorizontal && totalValue) {
const perc = Math.round((Number(tooltipItem.raw) / totalValue) * 100)
value = `${tooltipItem.label.toLocaleString()} (${perc}%)`
}
let showCountedByTag = false
let numberOfSeries = 1
if (tooltipDatasets.find((item) => item[actionObjKey])) {
// The above statement will always be true except in Sessions tab
showCountedByTag = !!tooltipDatasets.find(
({ [actionObjKey]: actionObj }) => actionObj?.math && actionObj.math !== 'total'
)
numberOfSeries = new Set(
tooltipDatasets.flatMap(({ [actionObjKey]: actionObj }) => actionObj?.order)
).size
}
// This could either be a color or an array of colors (`horizontalBar`)
const colorSet = entityData.backgroundColor || entityData.borderColor
return (
<InsightLabel
action={action}
seriesColor={isHorizontal ? colorSet[tooltipItem.dataIndex] : colorSet}
value={value}
fallbackName={label}
showCountedByTag={showCountedByTag}
hasMultipleSeries={numberOfSeries > 1}
breakdownValue={
entityData.breakdownValues // Used in `horizontalBar`
? entityData.breakdownValues[tooltipItem.dataIndex] === ''
? 'None'
: entityData.breakdownValues[tooltipItem.dataIndex]
: entityData.breakdown_value === ''
? 'None'
: entityData.breakdown_value
}
seriesStatus={entityData.status}
useCustomName
/>
)
},
},
},
...(!isBar
? {
crosshair: {
snap: {
enabled: true, // Snap crosshair to data points
},
sync: {
enabled: false, // Sync crosshairs across multiple Chartjs instances
},
zoom: {
enabled: false, // Allow drag to zoom
},
line: {
color: colors.crosshair,
width: 1,
},
},
}
: {
crosshair: false,
}),
},
hover: {
mode: isBar ? 'point' : 'nearest',
axis: isHorizontal ? 'y' : 'x',
intersect: false,
},
onHover(event: ChartEvent, _: ActiveElement[], chart: Chart) {
const nativeEvent = event.native
if (!nativeEvent) {
return
}
const target = nativeEvent?.target as HTMLDivElement
const point = chart.getElementsAtEventForMode(nativeEvent, 'index', { intersect: true }, true)
if (onClick && point.length) {
target.style.cursor = 'pointer'
} else {
target.style.cursor = 'default'
}
},
onClick: (event: ChartEvent, _: ActiveElement[], chart: Chart) => {
const nativeEvent = event.native
if (!nativeEvent) {
return
}
// Get all points along line
const sortDirection = isHorizontal ? 'x' : 'y'
const sortPoints = (a: InteractionItem, b: InteractionItem): number =>
Math.abs(a.element[sortDirection] - (event[sortDirection] ?? 0)) -
Math.abs(b.element[sortDirection] - (event[sortDirection] ?? 0))
const pointsIntersectingLine = chart
.getElementsAtEventForMode(
nativeEvent,
isHorizontal ? 'y' : 'index',
{
intersect: false,
},
true
)
.sort(sortPoints)
// Get all points intersecting clicked point
const pointsIntersectingClick = chart
.getElementsAtEventForMode(
nativeEvent,
'point',
{
intersect: true,
},
true
)
.sort(sortPoints)
if (!pointsIntersectingClick.length && !pointsIntersectingLine.length) {
return
}
const clickedPointNotLine = pointsIntersectingClick.length !== 0
onClick?.({
points: {
pointsIntersectingLine: pointsIntersectingLine.map((p) => ({
...p,
dataset: datasets[p.datasetIndex],
})),
pointsIntersectingClick: pointsIntersectingClick.map((p) => ({
...p,
dataset: datasets[p.datasetIndex],
})),
clickedPointNotLine,
},
index: clickedPointNotLine
? pointsIntersectingClick?.[0]?.index
: pointsIntersectingLine?.[0]?.index,
})
},
}
if (type === GraphType.Bar) {
options.scales = {
x: {
min: 0,
beginAtZero: true,
stacked: true,
ticks: {
precision: 0,
color: colors.axisLabel as string,
},
},
y: {
min: 0,
beginAtZero: true,
stacked: true,
ticks: {
precision: 0,
color: colors.axisLabel as string,
callback: (value) => {
return compactNumber(Number(value))
},
},
},
}
} else if (type === GraphType.Line) {
options.scales = {
x: {
min: 0,
beginAtZero: true,
display: true,
ticks: tickOptions,
grid: {
display: false,
borderColor: colors.axisLine as string,
tickLength: annotationInRange || focused ? 45 : 10,
},
},
y: {
min: 0,
beginAtZero: true,
display: true,
ticks: {
precision: 0,
...(percentage
? {
callback: function (value) {
const numVal = Number(value)
const fixedValue = numVal < 1 ? numVal.toFixed(2) : numVal.toFixed(0)
return `${fixedValue}%` // convert it to percentage
},
}
: {
...tickOptions,
callback: (value) => {
return compactNumber(Number(value))
},
}),
},
grid: {
borderColor: colors.axisLine as string,
},
},
}
} else if (isHorizontal) {
options.scales = {
x: {
min: 0,
beginAtZero: true,
display: true,
ticks: {
...tickOptions,
precision: 0,
callback: (value) => {
return compactNumber(Number(value))
},
},
},
y: {
min: 0,
beginAtZero: true,
ticks: {
precision: 0,
color: colors.axisLabel as string,
},
},
}
options.indexAxis = 'y'
} else if (type === GraphType.Pie) {
options = {
responsive: true,
maintainAspectRatio: false,
hover: {
mode: 'index',
},
onHover: options.onHover,
plugins: {
legend: {
display: false,
},
crosshair: false as CrosshairOptions,
},
onClick: options.onClick,
}
}
myLineChart.current = new Chart(myChartRef as ChartItem, {
type: (isBar ? GraphType.Bar : type) as ChartType,
data: { labels, datasets },
options,
})
}
return (
<div
className="graph-container"
data-attr={dataAttr}
onMouseMove={(e) => {
setEnabled(true)
if (annotationsCondition && myLineChart.current) {
const rect = e.currentTarget.getBoundingClientRect(),
offsetX = e.clientX - rect.left,
offsetY = e.clientY - rect.top
if (offsetY < topExtent - 30 && !focused && !annotationsFocused) {
setEnabled(false)
setLeft(-1)
return
}
const xAxis = myLineChart.current.scales.x,
_leftExtent = xAxis.left,
_rightExtent = xAxis.right,
ticks = xAxis.ticks.length,
delta = _rightExtent - _leftExtent,
_interval = delta / (ticks - 1)
if (offsetX < _leftExtent - _interval / 2) {
return
}
const index = mapRange(offsetX, _leftExtent - _interval / 2, _rightExtent + _interval / 2, 0, ticks)
if (index >= 0 && index < ticks && offsetY >= topExtent - 30) {
setLeft(index * _interval + _leftExtent)
setLabelIndex(index)
}
}
}}
onMouseLeave={() => setEnabled(false)}
>
<canvas ref={chartRef} />
<div className="annotations-root" ref={annotationsRoot} />
{annotationsCondition && (
<Annotations
dates={datasets[0].days ?? []}
leftExtent={leftExtent}
interval={boundaryInterval}
topExtent={topExtent}
insightId={insightId}
currentDateMarker={
focused || annotationsFocused
? selectedDayLabel
: enabled && labelIndex
? datasets[0].days?.[labelIndex]
: null
}
onClick={() => {
setFocused(false)
setAnnotationsFocused(true)
}}
onClose={() => {
setAnnotationsFocused(false)
}}
graphColor={color}
color={colors.annotationColor}
accessoryColor={colors.annotationAccessoryColor}
/>
)}
{annotationsCondition && !annotationsFocused && (enabled || focused) && left >= 0 && (
<AnnotationMarker
insightId={insightId}
currentDateMarker={focused ? selectedDayLabel : labelIndex ? datasets[0].days?.[labelIndex] : null}
onClick={() => {
setFocused(true)
setHoldLeft(left)
setHoldLabelIndex(labelIndex)
setSelectedDayLabel(labelIndex ? datasets[0].days?.[labelIndex] ?? null : null)
}}
getPopupContainer={
annotationsRoot?.current ? () => annotationsRoot.current as HTMLDivElement : undefined
}
onCreateAnnotation={(textInput, applyAll) => {
const date = holdLabelIndex ? datasets[0].days?.[holdLabelIndex] : null
if (date) {
if (applyAll) {
createGlobalAnnotation(textInput, date, insightId)
} else {
createAnnotation(textInput, date)
}
}
}}
onClose={() => setFocused(false)}
dynamic={true}
left={(focused ? holdLeft : left) - 12.5}
top={topExtent}
label={'Add Note'}
graphColor={color}
color={colors.annotationColor}
accessoryColor={colors.annotationAccessoryColor}
/>
)}
</div>
)
}

View File

@ -1 +0,0 @@
export * from './LineGraph.jsx'

View File

@ -1,11 +1,11 @@
import React, { useState } from 'react'
import { retentionTableLogic } from './retentionTableLogic'
import { LineGraph } from '../insights/LineGraph'
import { LineGraph } from '../insights/LineGraph/LineGraph'
import { useActions, useValues } from 'kea'
import { InsightEmptyState } from '../insights/EmptyStates'
import { Modal, Button } from 'antd'
import { PersonsTable } from 'scenes/persons/PersonsTable'
import { PersonType } from '~/types'
import { GraphType, PersonType, GraphDataset } from '~/types'
import { RetentionTrendPeoplePayload } from 'scenes/retention/types'
import { insightLogic } from 'scenes/insights/insightLogic'
import './RetentionLineGraph.scss'
@ -22,7 +22,7 @@ export function RetentionLineGraph({
color = 'white',
inSharedMode = false,
}: RetentionLineGraphProps): JSX.Element | null {
const { insightProps } = useValues(insightLogic)
const { insightProps, insight } = useValues(insightLogic)
const logic = retentionTableLogic(insightProps)
const { filters, trendSeries, people: _people, peopleLoading, loadingMore } = useValues(logic)
const people = _people as RetentionTrendPeoplePayload
@ -33,7 +33,7 @@ export function RetentionLineGraph({
function closeModal(): void {
setModalVisible(false)
}
const peopleData = people?.result as PersonType[]
const peopleData = people?.result ?? ([] as PersonType[])
const peopleNext = people?.next
if (trendSeries.length === 0) {
return null
@ -43,17 +43,17 @@ export function RetentionLineGraph({
<>
<LineGraph
data-attr="trend-line-graph"
type="line"
type={GraphType.Line}
color={color}
datasets={trendSeries}
datasets={trendSeries as GraphDataset[]}
labels={(trendSeries[0] && trendSeries[0].labels) || []}
isInProgress={!filters.date_to}
dashboardItemId={dashboardItemId}
inSharedMode={inSharedMode}
insightId={insight.id}
inSharedMode={!!inSharedMode}
percentage={true}
onClick={
dashboardItemId
? null
? undefined
: (point) => {
const { index } = point
loadPeople(index) // start from 0

View File

@ -9,7 +9,7 @@ import {
ACTIONS_BAR_CHART,
ACTIONS_BAR_CHART_VALUE,
} from 'lib/constants'
import { ActionsPie, ActionsLineGraph, ActionsBarValueGraph, ActionsTable } from './viz'
import { ActionsPie, ActionsLineGraph, ActionsHorizontalBar, ActionsTable } from './viz'
import { SaveCohortModal } from './SaveCohortModal'
import { trendsLogic } from './trendsLogic'
import { InsightType } from '~/types'
@ -67,7 +67,7 @@ export function TrendInsight({ view }: Props): JSX.Element {
return <ActionsPie filters={_filters} />
}
if (_filters.display === ACTIONS_BAR_CHART_VALUE) {
return <ActionsBarValueGraph filters={_filters} />
return <ActionsHorizontalBar filters={_filters} />
}
}

View File

@ -1,15 +1,15 @@
import React, { useState, useEffect } from 'react'
import { LineGraph } from '../../insights/LineGraph'
import { LineGraph } from '../../insights/LineGraph/LineGraph'
import { getChartColors } from 'lib/colors'
import { useActions, useValues } from 'kea'
import { trendsLogic } from 'scenes/trends/trendsLogic'
import { InsightEmptyState } from '../../insights/EmptyStates'
import { FilterType, TrendResultWithAggregate } from '~/types'
import { ActionFilter, FilterType, GraphType, InsightShortId } from '~/types'
import { personsModalLogic } from '../personsModalLogic'
import { insightLogic } from 'scenes/insights/insightLogic'
interface Props {
dashboardItemId?: number | null
dashboardItemId?: InsightShortId | null
filters: Partial<FilterType>
color?: string
inSharedMode?: boolean | null
@ -19,7 +19,7 @@ interface Props {
type DataSet = any
export function ActionsBarValueGraph({
export function ActionsHorizontalBar({
dashboardItemId = null,
filters: filtersParam,
color = 'white',
@ -27,13 +27,13 @@ export function ActionsBarValueGraph({
}: Props): JSX.Element | null {
const [data, setData] = useState<DataSet[] | null>(null)
const [total, setTotal] = useState(0)
const { insightProps } = useValues(insightLogic)
const { insightProps, insight } = useValues(insightLogic)
const logic = trendsLogic(insightProps)
const { loadPeople, loadPeopleFromUrl } = useActions(personsModalLogic)
const { results } = useValues(logic)
function updateData(): void {
const _data = [...results] as TrendResultWithAggregate[]
const _data = [...results]
_data.sort((a, b) => b.aggregated_value - a.aggregated_value)
// If there are more series than colors, we reuse colors sequentially so all series are colored
@ -46,7 +46,7 @@ export function ActionsBarValueGraph({
labels: _data.map((item) => item.label),
data: _data.map((item) => item.aggregated_value),
actions: _data.map((item) => item.action),
persons: _data.map((item) => item.persons),
personsValues: _data.map((item) => item.persons),
days,
breakdownValues: _data.map((item) => item.breakdown_value),
backgroundColor: colorList,
@ -69,32 +69,39 @@ export function ActionsBarValueGraph({
return data && total > 0 ? (
<LineGraph
data-attr="trend-bar-value-graph"
type="horizontalBar"
type={GraphType.HorizontalBar}
color={color}
datasets={data}
labels={data[0].labels}
dashboardItemId={dashboardItemId}
insightId={insight.id}
totalValue={total}
interval={filtersParam?.interval}
onClick={
dashboardItemId || filtersParam.formula || !showPersonsModal
? null
? undefined
: (point) => {
const { dataset, value: pointValue, index } = point
const action = dataset.actions[point.index]
const label = dataset.labels[point.index]
const { value: pointValue, index, points } = point
// For now, take first point when clicking a specific point.
// TODO: Implement case when if the entire line was clicked, show people for that entire day across actions.
const dataset = points.clickedPointNotLine
? points.pointsIntersectingClick[0].dataset
: points.pointsIntersectingLine[0].dataset
const action = dataset.actions?.[point.index]
const label = dataset.labels?.[point.index]
const date_from = filtersParam?.date_from || ''
const date_to = filtersParam?.date_to || ''
const breakdown_value = dataset.breakdownValues[point.index]
const breakdown_value = dataset.breakdownValues?.[point.index]
? dataset.breakdownValues[point.index]
: null
const params = {
action,
label,
action: action as ActionFilter,
label: label ?? '',
date_from,
date_to,
filters: filtersParam,
breakdown_value,
breakdown_value: breakdown_value ?? '',
pointValue,
}
if (dataset.persons_urls?.[index].url) {

View File

@ -1,11 +1,10 @@
import React from 'react'
import { LineGraph } from '../../insights/LineGraph'
import { LineGraph } from '../../insights/LineGraph/LineGraph'
import { useActions, useValues } from 'kea'
import { trendsLogic } from 'scenes/trends/trendsLogic'
import { InsightEmptyState } from '../../insights/EmptyStates'
import { ACTIONS_BAR_CHART } from 'lib/constants'
import { ChartParams } from '~/types'
import { InsightType } from '~/types'
import { ActionFilter, ChartParams, GraphType, InsightType } from '~/types'
import { personsModalLogic } from '../personsModalLogic'
import { insightLogic } from 'scenes/insights/insightLogic'
import { isMultiSeriesFormula } from 'lib/utils'
@ -16,7 +15,7 @@ export function ActionsLineGraph({
inSharedMode = false,
showPersonsModal = true,
}: ChartParams): JSX.Element | null {
const { insightProps } = useValues(insightLogic)
const { insightProps, isViewedOnDashboard, insight } = useValues(insightLogic)
const logic = trendsLogic(insightProps)
const { filters, indexedResults, visibilityMap } = useValues(logic)
const { loadPeople, loadPeopleFromUrl } = useActions(personsModalLogic)
@ -26,13 +25,17 @@ export function ActionsLineGraph({
indexedResults.filter((result) => result.count !== 0).length > 0 ? (
<LineGraph
data-attr="trend-line-graph"
type={filters.insight === InsightType.LIFECYCLE || filters.display === ACTIONS_BAR_CHART ? 'bar' : 'line'}
type={
filters.insight === InsightType.LIFECYCLE || filters.display === ACTIONS_BAR_CHART
? GraphType.Bar
: GraphType.Line
}
color={color}
datasets={indexedResults}
visibilityMap={visibilityMap}
labels={(indexedResults[0] && indexedResults[0].labels) || []}
isInProgress={!filters.date_to}
dashboardItemId={dashboardItemId}
insightId={insight.id}
inSharedMode={inSharedMode}
interval={filters.interval}
showPersonsModal={showPersonsModal}
@ -40,20 +43,31 @@ export function ActionsLineGraph({
isCompare={!!filters.compare}
onClick={
dashboardItemId || isMultiSeriesFormula(filters.formula) || !showPersonsModal
? null
: (point) => {
const { dataset, day, value: pointValue, index } = point
? undefined
: (payload) => {
const { index, points } = payload
// For now, take first point when clicking a specific point.
// TODO: Implement case when if the entire line was clicked, show people for that entire day across actions.
const dataset = points.clickedPointNotLine
? points.pointsIntersectingClick[0].dataset
: points.pointsIntersectingLine[0].dataset
const day = dataset?.days?.[index] ?? ''
const label = dataset?.label ?? dataset?.labels?.[index] ?? ''
if (!dataset) {
return
}
const params = {
action: dataset.action || 'session',
label: dataset.label,
action: (dataset.action || 'session') as ActionFilter | 'session',
label,
date_from: day,
date_to: day,
filters: filters,
breakdown_value:
dataset.breakdown_value === undefined ? dataset.status : dataset.breakdown_value,
filters,
breakdown_value: points.clickedPointNotLine ? dataset.breakdown_value : undefined,
saveOriginal: true,
pointValue,
pointValue: dataset?.data?.[index] ?? undefined,
}
if (dataset.persons_urls?.[index].url) {
loadPeopleFromUrl({
@ -67,6 +81,6 @@ export function ActionsLineGraph({
}
/>
) : (
<InsightEmptyState color={color} isDashboard={!!dashboardItemId} />
<InsightEmptyState color={color} isDashboard={isViewedOnDashboard} />
)
}

View File

@ -1,12 +1,11 @@
import './ActionsPie.scss'
import React, { useState, useEffect } from 'react'
import { maybeAddCommasToInteger } from 'lib/utils'
import { LineGraph } from '../../insights/LineGraph'
import { LineGraph } from '../../insights/LineGraph/LineGraph'
import { getChartColors } from 'lib/colors'
import { useValues, useActions } from 'kea'
import { trendsLogic } from 'scenes/trends/trendsLogic'
import { ChartParams, TrendResultWithAggregate } from '~/types'
import { ChartParams, GraphType, GraphDataset, ActionFilter } from '~/types'
import { personsModalLogic } from '../personsModalLogic'
import { insightLogic } from 'scenes/insights/insightLogic'
@ -17,15 +16,15 @@ export function ActionsPie({
inSharedMode,
showPersonsModal = true,
}: ChartParams): JSX.Element | null {
const [data, setData] = useState<Record<string, any>[] | null>(null)
const [data, setData] = useState<GraphDataset[] | null>(null)
const [total, setTotal] = useState(0)
const { insightProps } = useValues(insightLogic)
const { insightProps, insight } = useValues(insightLogic)
const logic = trendsLogic(insightProps)
const { loadPeople, loadPeopleFromUrl } = useActions(personsModalLogic)
const { results } = useValues(logic)
function updateData(): void {
const _data = [...results] as TrendResultWithAggregate[]
const _data = [...results]
_data.sort((a, b) => b.aggregated_value - a.aggregated_value)
const days = results.length > 0 ? results[0].days : []
@ -33,11 +32,12 @@ export function ActionsPie({
setData([
{
id: 0,
labels: _data.map((item) => item.label),
data: _data.map((item) => item.aggregated_value),
actions: _data.map((item) => item.action),
breakdownValues: _data.map((item) => item.breakdown_value),
persons: _data.map((item) => item.persons),
personsValues: _data.map((item) => item.persons),
days,
backgroundColor: colorList,
hoverBackgroundColor: colorList,
@ -63,30 +63,31 @@ export function ActionsPie({
<LineGraph
data-attr="trend-pie-graph"
color={color}
type="doughnut"
type={GraphType.Pie}
datasets={data}
labels={data[0].labels}
inSharedMode={inSharedMode}
dashboardItemId={dashboardItemId}
inSharedMode={!!inSharedMode}
insightId={insight.id}
onClick={
dashboardItemId || filtersParam.formula || !showPersonsModal
? null
: (point) => {
const { dataset, index } = point
const action = dataset.actions[point.index]
const label = dataset.labels[point.index]
? undefined
: (payload) => {
const { points, index } = payload
const dataset = points.pointsIntersectingClick?.[0]?.dataset
const action = dataset.actions?.[index]
const label = dataset.labels?.[index]
const date_from = filtersParam.date_from || ''
const date_to = filtersParam.date_to || ''
const breakdown_value = dataset.breakdownValues[point.index]
? dataset.breakdownValues[point.index]
const breakdown_value = dataset.breakdownValues?.[index]
? dataset.breakdownValues[index]
: null
const params = {
action,
label,
action: action as ActionFilter,
label: label ?? '',
date_from,
date_to,
filters: filtersParam,
breakdown_value,
breakdown_value: breakdown_value ?? '',
}
if (dataset.persons_urls?.[index].url) {
loadPeopleFromUrl({

View File

@ -4,7 +4,7 @@ import { Table } from 'antd'
import PropTypes from 'prop-types'
import { useValues } from 'kea'
import { trendsLogic } from 'scenes/trends/trendsLogic'
import { ActionFilter, TrendResultWithAggregate } from '~/types'
import { ActionFilter } from '~/types'
import { insightLogic } from 'scenes/insights/insightLogic'
export function ActionsTable(): JSX.Element {
@ -12,7 +12,7 @@ export function ActionsTable(): JSX.Element {
const logic = trendsLogic(insightProps)
const { filters, indexedResults, resultsLoading } = useValues(logic)
let data = indexedResults as any as TrendResultWithAggregate[]
let data = indexedResults
if (!filters.session && data) {
data = [...data].sort((a, b) => b.aggregated_value - a.aggregated_value)
}

View File

@ -1,4 +1,4 @@
export * from './ActionsPie'
export * from './ActionsLineGraph'
export * from './ActionsTable'
export * from './ActionsBarValueGraph'
export * from './ActionsHorizontalBar'

View File

@ -22,6 +22,7 @@ import { PostHog } from 'posthog-js'
import React from 'react'
import { PopupProps } from 'lib/components/Popup/Popup'
import { dayjs } from 'lib/dayjs'
import { ChartDataset, ChartType, InteractionItem } from 'chart.js'
export type Optional<T, K extends string | number | symbol> = Omit<T, K> & { [K in keyof T]?: T[K] }
@ -992,6 +993,7 @@ export interface ActionFilter extends EntityFilter {
export interface TrendResult {
action: ActionFilter
actions?: ActionFilter[]
count: number
data: number[]
days: string[]
@ -1002,14 +1004,14 @@ export interface TrendResult {
aggregated_value: number
status?: string
compare_label?: string
compare?: boolean
persons_urls?: { url: string }[]
persons?: Person
}
export interface TrendResultWithAggregate extends TrendResult {
aggregated_value: number
persons: {
url: string
filter: Partial<FilterType>
}
interface Person {
url: string
filter: Partial<FilterType>
}
export interface FunnelStep {
@ -1494,3 +1496,49 @@ export interface Breadcrumb {
/** Whether to show a custom popup */
popup?: Pick<PopupProps, 'overlay' | 'sameWidth' | 'actionable'>
}
export enum GraphType {
Bar = 'bar',
HorizontalBar = 'horizontalBar',
Line = 'line',
Histogram = 'histogram',
Pie = 'doughnut',
}
export type GraphDataset = ChartDataset<ChartType> &
Partial<
Pick<
TrendResult,
| 'count'
| 'label'
| 'days'
| 'labels'
| 'data'
| 'compare'
| 'status'
| 'action'
| 'actions'
| 'breakdown_value'
| 'persons_urls'
| 'persons'
>
> & {
id: number // used in filtering out visibility of datasets. Set internally by chart.js
dotted?: boolean // toggled on to draw incompleteness lines in LineGraph.tsx
breakdownValues?: (string | number | undefined)[] // array of breakdown values used only in ActionsHorizontalBar.tsx data
personsValues?: (Person | undefined)[] // array of persons ussed only in (ActionsHorizontalBar|ActionsPie).tsx
}
interface PointsPayload {
pointsIntersectingLine: (InteractionItem & { dataset: GraphDataset })[]
pointsIntersectingClick: (InteractionItem & { dataset: GraphDataset })[]
clickedPointNotLine: boolean
}
export interface GraphPointPayload {
points: PointsPayload
index: number
label?: string // Soon to be deprecated with LEGACY_LineGraph
day?: string // Soon to be deprecated with LEGACY_LineGraph
value?: number
}

View File

@ -76,8 +76,9 @@
"antd": "^4.17.1",
"antd-dayjs-webpack-plugin": "^1.0.6",
"babel-preset-nano-react-app": "^0.1.0",
"chart.js": "^3.6.2",
"chartjs-adapter-dayjs": "^1.0.0",
"chartjs-plugin-crosshair": "^1.1.6",
"chartjs-plugin-crosshair": "^1.2.0",
"clsx": "^1.1.1",
"core-js": "3.15.2",
"d3": "^5.15.0",
@ -134,7 +135,8 @@
"@storybook/react": "^6.3.11",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/chart.js": "^2.9.32",
"@types/chart.js": "^2.9.34",
"@types/chartjs-plugin-crosshair": "^1.1.1",
"@types/d3": "^7.0.0",
"@types/jest": "^26.0.15",
"@types/query-selector-shadow-dom": "^1.0.0",

View File

@ -3526,13 +3526,20 @@
resolved "https://registry.yarnpkg.com/@types/braces/-/braces-3.0.1.tgz#5a284d193cfc61abb2e5a50d36ebbc50d942a32b"
integrity sha512-+euflG6ygo4bn0JHtn4pYqcXwRtLvElQ7/nnjDu7iYG56H0+OhCd7d6Ug0IE3WcFpZozBKW2+80FUbv5QGk5AQ==
"@types/chart.js@^2.9.32":
version "2.9.32"
resolved "https://registry.yarnpkg.com/@types/chart.js/-/chart.js-2.9.32.tgz#b17d9a8c41ad348183a2ce041ebdeef892998251"
integrity sha512-d45JiRQwEOlZiKwukjqmqpbqbYzUX2yrXdH9qVn6kXpPDsTYCo6YbfFOlnUaJ8S/DhJwbBJiLsMjKpW5oP8B2A==
"@types/chart.js@*", "@types/chart.js@^2.9.34":
version "2.9.34"
resolved "https://registry.yarnpkg.com/@types/chart.js/-/chart.js-2.9.34.tgz#d41fb6b74b72a56bac70255bb4ed087386b66ed6"
integrity sha512-CtZVk+kh1IN67dv+fB0CWmCLCRrDJgqOj15qPic2B1VCMovNO6B7Vhf/TgPpNscjhAL1j+qUntDMWb9A4ZmPTg==
dependencies:
moment "^2.10.2"
"@types/chartjs-plugin-crosshair@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/chartjs-plugin-crosshair/-/chartjs-plugin-crosshair-1.1.1.tgz#9faa4243e9db32d9d19c9dcb24fed7daa5820001"
integrity sha512-fAO7SX8sJi4gJS0afd/50JFtzg9vNhQPwNx6Kjo5FLyibgmwzMiqO9LnSIvR6OWiX3oN/mDrAuvqKCRs6cFLNw==
dependencies:
"@types/chart.js" "*"
"@types/color-convert@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/color-convert/-/color-convert-2.0.0.tgz#8f5ee6b9e863dcbee5703f5a517ffb13d3ea4e22"
@ -5816,13 +5823,10 @@ charenc@0.0.2:
resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
chart.js@^2.9.3:
version "2.9.4"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.4.tgz#0827f9563faffb2dc5c06562f8eb10337d5b9684"
integrity sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==
dependencies:
chartjs-color "^2.1.0"
moment "^2.10.2"
chart.js@^3.6.2:
version "3.6.2"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.6.2.tgz#47342c551f688ffdda2cd53b534cb7e461ecec33"
integrity sha512-Xz7f/fgtVltfQYWq0zL1Xbv7N2inpG+B54p3D5FSvpCdy3sM+oZhbqa42eNuYXltaVvajgX5UpKCU2GeeJIgxg==
chartjs-adapter-dayjs@^1.0.0:
version "1.0.0"
@ -5844,12 +5848,10 @@ chartjs-color@^2.1.0:
chartjs-color-string "^0.6.0"
color-convert "^1.9.3"
chartjs-plugin-crosshair@^1.1.6:
version "1.1.6"
resolved "https://registry.yarnpkg.com/chartjs-plugin-crosshair/-/chartjs-plugin-crosshair-1.1.6.tgz#3874c3114a5c2ea5d5569a200ef0b32425107ed2"
integrity sha512-faqHKnZzhNYh2ahIKaxTWB9ZX5afRJkQ+7jsZ8u0Z+PE4YPcs1jNCCKwWINWacYaryh6ih/OT6pLmMnkNMusHQ==
dependencies:
chart.js "^2.9.3"
chartjs-plugin-crosshair@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/chartjs-plugin-crosshair/-/chartjs-plugin-crosshair-1.2.0.tgz#54c8223ed40db4a5c380f17967fcf1a11cb2a284"
integrity sha512-yohsbME+wT1ODBdErBzWnC6xUDcn2tLeWmGjGDTykjpiT7+FMgibffajxqaCVmdzselxNPirpt76vx33EewSSQ==
"chokidar@>=2.0.0 <4.0.0", chokidar@^3.4.1:
version "3.5.1"