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:
parent
9c60029818
commit
099cbd0fc3
7
frontend/src/custom.d.ts
vendored
7
frontend/src/custom.d.ts
vendored
@ -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 }
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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}</>
|
||||
}
|
||||
|
@ -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(),
|
||||
}),
|
||||
})
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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 && (
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
720
frontend/src/scenes/insights/LineGraph/LineGraph.tsx
Normal file
720
frontend/src/scenes/insights/LineGraph/LineGraph.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './LineGraph.jsx'
|
@ -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
|
||||
|
@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
@ -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} />
|
||||
)
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
export * from './ActionsPie'
|
||||
export * from './ActionsLineGraph'
|
||||
export * from './ActionsTable'
|
||||
export * from './ActionsBarValueGraph'
|
||||
export * from './ActionsHorizontalBar'
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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",
|
||||
|
36
yarn.lock
36
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user