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

feat(BI): Series settings (#24082)

Co-authored-by: Eric Duong <eric@posthog.com>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Tom Owers 2024-07-31 12:21:32 +01:00 committed by GitHub
parent 668b792254
commit af4ef0bd3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 354 additions and 100 deletions

View File

@ -63,6 +63,8 @@ export interface LemonButtonPropsBase
disabled?: boolean
/** Like plain `disabled`, except we enforce a reason to be shown in the tooltip. */
disabledReason?: string | null | false
/** Class for the wrapping div when the button is disabled */
disabledReasonWrapperClass?: string
noPadding?: boolean
size?: 'xsmall' | 'small' | 'medium' | 'large'
'data-attr'?: string
@ -116,6 +118,7 @@ export const LemonButton: React.FunctionComponent<LemonButtonProps & React.RefAt
className,
disabled,
disabledReason,
disabledReasonWrapperClass,
loading,
type = 'tertiary',
status = 'default',
@ -242,7 +245,11 @@ export const LemonButton: React.FunctionComponent<LemonButtonProps & React.RefAt
workingButton = (
<Tooltip title={tooltipContent} placement={tooltipPlacement}>
{/* If the button is a `button` element and disabled, wrap it in a div so that the tooltip works */}
{disabled && ButtonComponent === 'button' ? <div>{workingButton}</div> : workingButton}
{disabled && ButtonComponent === 'button' ? (
<div className={clsx(disabledReasonWrapperClass)}>{workingButton}</div>
) : (
workingButton
)}
</Tooltip>
)
}

View File

@ -289,8 +289,9 @@ limit 100`,
const HogQLForDataWarehouse: HogQLQuery = {
kind: NodeKind.HogQLQuery,
query: `select toDate(timestamp) as timestamp, event as event
from events
query: `select toDate(timestamp) as timestamp, count()
from events
group by timestamp
limit 100`,
explain: true,
}

View File

@ -19,7 +19,7 @@ import { ensureTooltip } from 'scenes/insights/views/LineGraph/LineGraph'
import { themeLogic } from '~/layout/navigation-3000/themeLogic'
import { ChartDisplayType, GraphType } from '~/types'
import { dataVisualizationLogic } from '../../dataVisualizationLogic'
import { dataVisualizationLogic, formatDataWithSettings } from '../../dataVisualizationLogic'
import { displayLogic } from '../../displayLogic'
Chart.register(annotationPlugin)
@ -179,9 +179,9 @@ export const LineGraph = (): JSX.Element => {
tooltipRoot.render(
<div className="InsightTooltip">
<LemonTable
dataSource={yData.map(({ data, column }) => ({
dataSource={yData.map(({ data, column, settings }) => ({
series: column.name,
data: data[referenceDataPoint.dataIndex],
data: formatDataWithSettings(data[referenceDataPoint.dataIndex], settings),
}))}
columns={[
{

View File

@ -1,12 +1,15 @@
import { IconPlusSmall, IconTrash } from '@posthog/icons'
import { LemonButton, LemonLabel, LemonSelect } from '@posthog/lemon-ui'
import { IconGear, IconPlusSmall, IconTrash } from '@posthog/icons'
import { LemonButton, LemonInput, LemonLabel, LemonSelect, Popover } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { dataVisualizationLogic } from '../dataVisualizationLogic'
import { AxisSeries, dataVisualizationLogic } from '../dataVisualizationLogic'
import { ySeriesLogic } from './ySeriesLogic'
export const SeriesTab = (): JSX.Element => {
const { columns, xData, yData, responseLoading } = useValues(dataVisualizationLogic)
const { updateXSeries, updateYSeries, addYSeries, deleteYSeries } = useActions(dataVisualizationLogic)
const { updateXSeries, addYSeries } = useActions(dataVisualizationLogic)
const options = columns.map(({ name, label }) => ({
value: name,
@ -29,29 +32,8 @@ export const SeriesTab = (): JSX.Element => {
}}
/>
<LemonLabel className="mt-4">Y-axis</LemonLabel>
{(yData ?? [null]).map((series, index) => (
<div className="flex gap-1 mb-1" key={series?.column.name}>
<LemonSelect
className="grow"
value={series !== null ? series.column.label : 'None'}
options={options}
disabledReason={responseLoading ? 'Query loading...' : undefined}
onChange={(value) => {
const column = columns.find((n) => n.name === value)
if (column) {
updateYSeries(index, column.name)
}
}}
/>
<LemonButton
key="delete"
icon={<IconTrash />}
status="danger"
title="Delete Y-series"
noPadding
onClick={() => deleteYSeries(index)}
/>
</div>
{yData.map((series, index) => (
<YSeries series={series} index={index} key={series?.column.name} />
))}
<LemonButton
className="mt-1"
@ -65,3 +47,77 @@ export const SeriesTab = (): JSX.Element => {
</div>
)
}
const YSeries = ({ series, index }: { series: AxisSeries<number>; index: number }): JSX.Element => {
const { columns, responseLoading, dataVisualizationProps } = useValues(dataVisualizationLogic)
const { updateYSeries, deleteYSeries } = useActions(dataVisualizationLogic)
const seriesLogicProps = { series, seriesIndex: index, dataVisualizationProps }
const seriesLogic = ySeriesLogic(seriesLogicProps)
const { isSettingsOpen, canOpenSettings } = useValues(seriesLogic)
const { setSettingsOpen, submitFormatting } = useActions(seriesLogic)
const options = columns.map(({ name, label }) => ({
value: name,
label,
}))
return (
<div className="flex gap-1 mb-1">
<LemonSelect
className="grow"
value={series !== null ? series.column.label : 'None'}
options={options}
disabledReason={responseLoading ? 'Query loading...' : undefined}
onChange={(value) => {
const column = columns.find((n) => n.name === value)
if (column) {
updateYSeries(index, column.name)
}
}}
/>
<Popover
overlay={
<Form logic={ySeriesLogic} props={seriesLogicProps} formKey="formatting" className="m-2 space-y-2">
<LemonField name="style" label="Style" className="gap-1">
<LemonSelect
options={[
{ value: 'none', label: 'None' },
{ value: 'number', label: 'Number' },
{ value: 'percent', label: 'Percentage' },
]}
/>
</LemonField>
<LemonField name="prefix" label="Prefix">
<LemonInput placeholder="$" />
</LemonField>
<LemonField name="suffix" label="Suffix">
<LemonInput placeholder="USD" />
</LemonField>
</Form>
}
visible={isSettingsOpen}
placement="bottom"
onClickOutside={() => submitFormatting()}
>
<LemonButton
key="seriesSettings"
icon={<IconGear />}
noPadding
onClick={() => setSettingsOpen(true)}
disabledReason={!canOpenSettings && 'Select a column first'}
disabledReasonWrapperClass="flex"
/>
</Popover>
<LemonButton
key="delete"
icon={<IconTrash />}
status="danger"
title="Delete Y-series"
noPadding
onClick={() => deleteYSeries(index)}
/>
</div>
)
}

View File

@ -0,0 +1,63 @@
import { actions, connect, kea, key, path, props, reducers, selectors } from 'kea'
import { forms } from 'kea-forms'
import {
AxisSeries,
dataVisualizationLogic,
DataVisualizationLogicProps,
EmptyYAxisSeries,
} from '../dataVisualizationLogic'
import type { ySeriesLogicType } from './ySeriesLogicType'
export interface YSeriesLogicProps {
series: AxisSeries<number>
seriesIndex: number
dataVisualizationProps: DataVisualizationLogicProps
}
export const ySeriesLogic = kea<ySeriesLogicType>([
path(['queries', 'nodes', 'DataVisualization', 'Components', 'ySeriesLogic']),
key((props) => props.series?.column?.name ?? `new-${props.seriesIndex}`),
connect((props: YSeriesLogicProps) => ({
actions: [dataVisualizationLogic(props.dataVisualizationProps), ['updateYSeries']],
})),
props({ series: EmptyYAxisSeries } as YSeriesLogicProps),
actions({
setSettingsOpen: (open: boolean) => ({ open }),
}),
reducers({
isSettingsOpen: [
false as boolean,
{
setSettingsOpen: (_, { open }) => open,
},
],
}),
selectors({
canOpenSettings: [
(_s, p) => [p.series],
(series) => {
return series !== EmptyYAxisSeries
},
],
}),
forms(({ actions, props }) => ({
formatting: {
defaults: {
prefix: props.series?.settings?.formatting?.prefix ?? '',
suffix: props.series?.settings?.formatting?.suffix ?? '',
style: props.series?.settings?.formatting?.style ?? 'none',
},
submit: async (format) => {
actions.updateYSeries(props.seriesIndex, props.series.column.name, {
formatting: {
prefix: format.prefix,
suffix: format.suffix,
style: format.style,
},
})
actions.setSettingsOpen(false)
},
},
})),
])

View File

@ -1,10 +1,11 @@
import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { subscriptions } from 'kea-subscriptions'
import mergeObject from 'lodash.merge'
import { insightSceneLogic } from 'scenes/insights/insightSceneLogic'
import { teamLogic } from 'scenes/teamLogic'
import { insightVizDataCollectionId } from '~/queries/nodes/InsightViz/InsightViz'
import { AnyResponseType, ChartAxis, DataVisualizationNode } from '~/queries/schema'
import { AnyResponseType, ChartAxis, ChartSettingsFormatting, DataVisualizationNode } from '~/queries/schema'
import { QueryContext } from '~/queries/types'
import { ChartDisplayType, InsightLogicProps, ItemMode } from '~/types'
@ -24,9 +25,14 @@ export interface Column {
dataIndex: number
}
export interface AxisSeriesSettings {
formatting?: ChartSettingsFormatting
}
export interface AxisSeries<T> {
column: Column
data: T[]
settings?: AxisSeriesSettings
}
export interface DataVisualizationLogicProps {
@ -38,6 +44,50 @@ export interface DataVisualizationLogicProps {
cachedResults?: AnyResponseType
}
export interface SelectedYAxis {
name: string
settings: AxisSeriesSettings
}
export const EmptyYAxisSeries: AxisSeries<number> = {
column: {
name: 'None',
type: 'None',
label: 'None',
dataIndex: -1,
},
data: [],
}
const DefaultAxisSettings: AxisSeriesSettings = {
formatting: {
prefix: '',
suffix: '',
},
}
export const formatDataWithSettings = (data: number, settings?: AxisSeriesSettings): string => {
let dataAsString = `${data}`
if (settings?.formatting?.style === 'number') {
dataAsString = data.toLocaleString()
}
if (settings?.formatting?.style === 'percent') {
dataAsString = `${(data * 100).toLocaleString()}%`
}
if (settings?.formatting?.prefix) {
dataAsString = `${settings.formatting.prefix}${dataAsString}`
}
if (settings?.formatting?.suffix) {
dataAsString = `${dataAsString}${settings.formatting.suffix}`
}
return dataAsString
}
export const dataVisualizationLogic = kea<dataVisualizationLogicType>([
key((props) => props.key),
path(['queries', 'nodes', 'DataVisualization', 'dataVisualizationLogic']),
@ -63,11 +113,12 @@ export const dataVisualizationLogic = kea<dataVisualizationLogicType>([
updateXSeries: (columnName: string) => ({
columnName,
}),
updateYSeries: (seriesIndex: number, columnName: string) => ({
updateYSeries: (seriesIndex: number, columnName: string, settings?: AxisSeriesSettings) => ({
seriesIndex,
columnName,
settings,
}),
addYSeries: (columnName?: string) => ({ columnName }),
addYSeries: (columnName?: string, settings?: AxisSeriesSettings) => ({ columnName, settings }),
deleteYSeries: (seriesIndex: number) => ({ seriesIndex }),
clearAxis: true,
setQuery: (node: DataVisualizationNode) => ({ node }),
@ -88,28 +139,36 @@ export const dataVisualizationLogic = kea<dataVisualizationLogicType>([
},
],
selectedYAxis: [
null as (string | null)[] | null,
null as (SelectedYAxis | null)[] | null,
{
clearAxis: () => null,
addYSeries: (state, { columnName }) => {
addYSeries: (state, { columnName, settings }) => {
if (!state && columnName !== undefined) {
return [columnName]
return [{ name: columnName, settings: settings ?? DefaultAxisSettings }]
}
if (!state) {
return [null]
}
return [...state, columnName === undefined ? null : columnName]
return [
...state,
columnName === undefined
? null
: { name: columnName, settings: settings ?? DefaultAxisSettings },
]
},
updateYSeries: (state, { seriesIndex, columnName }) => {
updateYSeries: (state, { seriesIndex, columnName, settings }) => {
if (!state) {
return null
}
const ySeries = [...state]
ySeries[seriesIndex] = columnName
ySeries[seriesIndex] = {
name: columnName,
settings: mergeObject(ySeries[seriesIndex]?.settings ?? {}, settings),
}
return ySeries
},
deleteYSeries: (state, { seriesIndex }) => {
@ -192,30 +251,22 @@ export const dataVisualizationLogic = kea<dataVisualizationLogicType>([
],
yData: [
(state) => [state.selectedYAxis, state.response, state.columns],
(ySeries, response, columns): null | AxisSeries<number>[] => {
(ySeries, response, columns): AxisSeries<number>[] => {
if (!response || ySeries === null || ySeries.length === 0) {
return null
return [EmptyYAxisSeries]
}
const data: any[] = response?.['results'] ?? response?.['result'] ?? []
return ySeries
.map((name): AxisSeries<number> | null => {
if (!name) {
return {
column: {
name: 'None',
type: 'None',
label: 'None',
dataIndex: -1,
},
data: [],
}
.map((series): AxisSeries<number> | null => {
if (!series) {
return EmptyYAxisSeries
}
const column = columns.find((n) => n.name === name)
const column = columns.find((n) => n.name === series.name)
if (!column) {
return null
return EmptyYAxisSeries
}
return {
@ -227,6 +278,7 @@ export const dataVisualizationLogic = kea<dataVisualizationLogicType>([
return 0
}
}),
settings: series.settings,
}
})
.filter((series): series is AxisSeries<number> => Boolean(series))
@ -260,6 +312,7 @@ export const dataVisualizationLogic = kea<dataVisualizationLogicType>([
}
},
],
dataVisualizationProps: [() => [(_, props) => props], (props): DataVisualizationLogicProps => props],
}),
listeners(({ props }) => ({
setQuery: ({ node }) => {
@ -290,7 +343,7 @@ export const dataVisualizationLogic = kea<dataVisualizationLogicType>([
if (yAxis && yAxis.length) {
yAxis.forEach((axis) => {
actions.addYSeries(axis.column)
actions.addYSeries(axis.column, axis.settings)
})
}
}
@ -325,22 +378,23 @@ export const dataVisualizationLogic = kea<dataVisualizationLogicType>([
},
selectedXAxis: (value: string | null) => {
if (props.setQuery) {
const yColumns = values.selectedYAxis?.filter((n: string | null): n is string => Boolean(n)) ?? []
const yColumns =
values.selectedYAxis?.filter((n: SelectedYAxis | null): n is SelectedYAxis => Boolean(n)) ?? []
const xColumn: ChartAxis | undefined = value !== null ? { column: value } : undefined
props.setQuery({
...props.query,
chartSettings: {
...(props.query.chartSettings ?? {}),
yAxis: yColumns.map((n) => ({ column: n })),
yAxis: yColumns.map((n) => ({ column: n.name, settings: n.settings })),
xAxis: xColumn,
},
})
}
},
selectedYAxis: (value: (string | null)[] | null) => {
selectedYAxis: (value: (SelectedYAxis | null)[] | null) => {
if (props.setQuery) {
const yColumns = value?.filter((n: string | null): n is string => Boolean(n)) ?? []
const yColumns = value?.filter((n: SelectedYAxis | null): n is SelectedYAxis => Boolean(n)) ?? []
const xColumn: ChartAxis | undefined =
values.selectedXAxis !== null ? { column: values.selectedXAxis } : undefined
@ -348,7 +402,7 @@ export const dataVisualizationLogic = kea<dataVisualizationLogicType>([
...props.query,
chartSettings: {
...(props.query.chartSettings ?? {}),
yAxis: yColumns.map((n) => ({ column: n })),
yAxis: yColumns.map((n) => ({ column: n.name, settings: n.settings })),
xAxis: xColumn,
},
})

View File

@ -1993,6 +1993,15 @@
"properties": {
"column": {
"type": "string"
},
"settings": {
"additionalProperties": false,
"properties": {
"formatting": {
"$ref": "#/definitions/ChartSettingsFormatting"
}
},
"type": "object"
}
},
"required": ["column"],
@ -2013,6 +2022,43 @@
],
"type": "string"
},
"ChartSettings": {
"additionalProperties": false,
"properties": {
"goalLines": {
"items": {
"$ref": "#/definitions/GoalLine"
},
"type": "array"
},
"xAxis": {
"$ref": "#/definitions/ChartAxis"
},
"yAxis": {
"items": {
"$ref": "#/definitions/ChartAxis"
},
"type": "array"
}
},
"type": "object"
},
"ChartSettingsFormatting": {
"additionalProperties": false,
"properties": {
"prefix": {
"type": "string"
},
"style": {
"enum": ["none", "number", "percent"],
"type": "string"
},
"suffix": {
"type": "string"
}
},
"type": "object"
},
"ClickhouseQueryProgress": {
"additionalProperties": false,
"properties": {
@ -2670,25 +2716,7 @@
"additionalProperties": false,
"properties": {
"chartSettings": {
"additionalProperties": false,
"properties": {
"goalLines": {
"items": {
"$ref": "#/definitions/GoalLine"
},
"type": "array"
},
"xAxis": {
"$ref": "#/definitions/ChartAxis"
},
"yAxis": {
"items": {
"$ref": "#/definitions/ChartAxis"
},
"type": "array"
}
},
"type": "object"
"$ref": "#/definitions/ChartSettings"
},
"display": {
"$ref": "#/definitions/ChartDisplayType"

View File

@ -551,9 +551,18 @@ export interface GoalLine {
export interface ChartAxis {
column: string
settings?: {
formatting?: ChartSettingsFormatting
}
}
interface ChartSettings {
export interface ChartSettingsFormatting {
prefix?: string
suffix?: string
style?: 'none' | 'number' | 'percent'
}
export interface ChartSettings {
xAxis?: ChartAxis
yAxis?: ChartAxis[]
goalLines?: GoalLine[]

View File

@ -139,6 +139,7 @@
"kea-test-utils": "^0.2.4",
"kea-waitfor": "^0.2.1",
"kea-window-values": "^3.0.0",
"lodash.merge": "^4.6.2",
"maplibre-gl": "^3.5.1",
"md5": "^2.3.0",
"monaco-editor": "^0.49.0",
@ -223,6 +224,7 @@
"@types/image-blob-reduce": "^4.1.1",
"@types/jest": "^29.5.12",
"@types/jest-image-snapshot": "^6.1.0",
"@types/lodash.merge": "^4.6.9",
"@types/md5": "^2.3.0",
"@types/node": "^18.11.9",
"@types/papaparse": "^5.3.8",

View File

@ -238,6 +238,9 @@ dependencies:
kea-window-values:
specifier: ^3.0.0
version: 3.0.0(kea@3.1.5)
lodash.merge:
specifier: ^4.6.2
version: 4.6.2
maplibre-gl:
specifier: ^3.5.1
version: 3.5.1
@ -488,6 +491,9 @@ devDependencies:
'@types/jest-image-snapshot':
specifier: ^6.1.0
version: 6.1.0
'@types/lodash.merge':
specifier: ^4.6.9
version: 4.6.9
'@types/node':
specifier: ^18.11.9
version: 18.11.9
@ -8355,6 +8361,12 @@ packages:
resolution: {integrity: sha512-PecSzorDGdabF57OBeQO/xFbAkYWo88g4Xvnsx7LRwqLC17I7OoKtA3bQB9uXkY6UkMWCOsA8HSVpaoitscdXw==}
dev: false
/@types/lodash.merge@4.6.9:
resolution: {integrity: sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ==}
dependencies:
'@types/lodash': 4.14.188
dev: true
/@types/lodash@4.14.188:
resolution: {integrity: sha512-zmEmF5OIM3rb7SbLCFYoQhO4dGt2FRM9AMkxvA3LaADOF1n8in/zGJlWji9fmafLoNyz+FoL6FE0SLtGIArD7w==}
dev: true
@ -15659,7 +15671,6 @@ packages:
/lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
/lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}

View File

@ -146,13 +146,6 @@ class StatusItem(BaseModel):
value: str
class ChartAxis(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
column: str
class ChartDisplayType(StrEnum):
ACTIONS_LINE_GRAPH = "ActionsLineGraph"
ACTIONS_BAR = "ActionsBar"
@ -166,6 +159,21 @@ class ChartDisplayType(StrEnum):
WORLD_MAP = "WorldMap"
class Style(StrEnum):
NONE = "none"
NUMBER = "number"
PERCENT = "percent"
class ChartSettingsFormatting(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
prefix: Optional[str] = None
style: Optional[Style] = None
suffix: Optional[str] = None
class ClickhouseQueryProgress(BaseModel):
model_config = ConfigDict(
extra="forbid",
@ -1851,6 +1859,30 @@ class CachedWebTopClicksQueryResponse(BaseModel):
types: Optional[list] = None
class Settings(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
formatting: Optional[ChartSettingsFormatting] = None
class ChartAxis(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
column: str
settings: Optional[Settings] = None
class ChartSettings(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
goalLines: Optional[list[GoalLine]] = None
xAxis: Optional[ChartAxis] = None
yAxis: Optional[list[ChartAxis]] = None
class Response(BaseModel):
model_config = ConfigDict(
extra="forbid",
@ -2009,15 +2041,6 @@ class Response7(BaseModel):
)
class ChartSettings(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
goalLines: Optional[list[GoalLine]] = None
xAxis: Optional[ChartAxis] = None
yAxis: Optional[list[ChartAxis]] = None
class DataWarehousePersonPropertyFilter(BaseModel):
model_config = ConfigDict(
extra="forbid",