diff --git a/frontend/src/lib/components/CommandPalette/commandPaletteLogic.tsx b/frontend/src/lib/components/CommandPalette/commandPaletteLogic.tsx index c160a82fe1016..a379db8200553 100644 --- a/frontend/src/lib/components/CommandPalette/commandPaletteLogic.tsx +++ b/frontend/src/lib/components/CommandPalette/commandPaletteLogic.tsx @@ -509,7 +509,7 @@ export const commandPaletteLogic = kea([ synonyms: ['hogql', 'sql'], executor: () => { // TODO: Don't reset insight on change - push(insightTypeURL[InsightType.SQL]) + push(insightTypeURL(Boolean(values.featureFlags[FEATURE_FLAGS.BI_VIZ]))[InsightType.SQL]) }, }, { diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index cd75a5b8ebb54..556535e73ce27 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -164,6 +164,7 @@ export const FEATURE_FLAGS = { HOGQL_INSIGHTS_LIFECYCLE: 'hogql-insights-lifecycle', // owner: @mariusandra HOGQL_INSIGHTS_TRENDS: 'hogql-insights-trends', // owner: @Gilbert09 HOGQL_INSIGHT_LIVE_COMPARE: 'hogql-insight-live-compare', // owner: @mariusandra + BI_VIZ: 'bi_viz', // owner: @Gilbert09 WEBHOOKS_DENYLIST: 'webhooks-denylist', // owner: #team-pipeline SURVEYS_RESULTS_VISUALIZATIONS: 'surveys-results-visualizations', // owner: @jurajmajerik SURVEYS_PAYGATES: 'surveys-paygates', diff --git a/frontend/src/queries/Query/Query.tsx b/frontend/src/queries/Query/Query.tsx index f3e00c66cee67..47372208e2205 100644 --- a/frontend/src/queries/Query/Query.tsx +++ b/frontend/src/queries/Query/Query.tsx @@ -10,11 +10,13 @@ import { QueryEditor } from '~/queries/QueryEditor/QueryEditor' import { AnyResponseType, Node, QuerySchema } from '~/queries/schema' import { QueryContext } from '~/queries/types' +import { DataTableVisualization } from '../nodes/DataVisualization/DataVisualization' import { SavedInsight } from '../nodes/SavedInsight/SavedInsight' import { TimeToSeeData } from '../nodes/TimeToSeeData/TimeToSeeData' import { isDataNode, isDataTableNode, + isDataVisualizationNode, isInsightVizNode, isSavedInsightNode, isTimeToSeeDataSessionsNode, @@ -76,6 +78,16 @@ export function Query(props: QueryProps): JSX.Element | null { uniqueKey={props.uniqueKey} /> ) + } else if (isDataVisualizationNode(query)) { + component = ( + + ) } else if (isDataNode(query)) { component = } else if (isSavedInsightNode(query)) { diff --git a/frontend/src/queries/examples.ts b/frontend/src/queries/examples.ts index 2485308f00a2a..3761896f7b279 100644 --- a/frontend/src/queries/examples.ts +++ b/frontend/src/queries/examples.ts @@ -3,6 +3,7 @@ import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils' import { ActionsNode, DataTableNode, + DataVisualizationNode, EventsNode, EventsQuery, FunnelsQuery, @@ -322,6 +323,11 @@ const HogQLTable: DataTableNode = { source: HogQLRaw, } +const DataVisualization: DataVisualizationNode = { + kind: NodeKind.DataVisualizationNode, + source: HogQLRaw, +} + /* a subset of examples including only those we can show all users and that don't use HogQL */ export const queryExamples: Record = { Events, @@ -353,6 +359,7 @@ export const examples: Record = { TimeToSeeDataJSON, HogQLRaw, HogQLTable, + DataVisualization, } export const stringifiedExamples: Record = Object.fromEntries( diff --git a/frontend/src/queries/nodes/DataTable/DataTable.tsx b/frontend/src/queries/nodes/DataTable/DataTable.tsx index ac5cce8d240d8..ade747f814a41 100644 --- a/frontend/src/queries/nodes/DataTable/DataTable.tsx +++ b/frontend/src/queries/nodes/DataTable/DataTable.tsx @@ -81,7 +81,7 @@ const personGroupTypes = [TaxonomicFilterGroupType.HogQLExpression, TaxonomicFil let uniqueNode = 0 export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }: DataTableProps): JSX.Element { - const uniqueNodeKey = useState(() => uniqueNode++) + const [uniqueNodeKey] = useState(() => uniqueNode++) const [dataKey] = useState(() => `DataNode.${uniqueKey || uniqueNodeKey}`) const [vizKey] = useState(() => `DataTable.${uniqueNodeKey}`) diff --git a/frontend/src/queries/nodes/DataVisualization/Components/Chart.scss b/frontend/src/queries/nodes/DataVisualization/Components/Chart.scss new file mode 100644 index 0000000000000..545b7a3ff171a --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/Components/Chart.scss @@ -0,0 +1,3 @@ +.DataVisualization { + --viz-min-height: calc(80vh - 6rem); +} diff --git a/frontend/src/queries/nodes/DataVisualization/Components/Chart.tsx b/frontend/src/queries/nodes/DataVisualization/Components/Chart.tsx new file mode 100644 index 0000000000000..2e9d5fc9ef17b --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/Components/Chart.tsx @@ -0,0 +1,24 @@ +import './Chart.scss' + +import { useValues } from 'kea' + +import { dataVisualizationLogic } from '../dataVisualizationLogic' +import { LineGraph } from './Charts/LineGraph' +import { ChartSelection } from './ChartSelection' + +export const Chart = (): JSX.Element => { + const { showEditingUI } = useValues(dataVisualizationLogic) + + return ( +
+ {showEditingUI && ( +
+ +
+ )} +
+ +
+
+ ) +} diff --git a/frontend/src/queries/nodes/DataVisualization/Components/ChartSelection.scss b/frontend/src/queries/nodes/DataVisualization/Components/ChartSelection.scss new file mode 100644 index 0000000000000..156205eed37e0 --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/Components/ChartSelection.scss @@ -0,0 +1,9 @@ +@import '../../../../styles/mixins'; + +.DataVisualization { + .ChartSelectionWrapper { + width: 20vw; + height: var(--viz-min-height); + border-radius: var(--radius); + } +} diff --git a/frontend/src/queries/nodes/DataVisualization/Components/ChartSelection.tsx b/frontend/src/queries/nodes/DataVisualization/Components/ChartSelection.tsx new file mode 100644 index 0000000000000..2d5b05677fd7e --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/Components/ChartSelection.tsx @@ -0,0 +1,45 @@ +import './ChartSelection.scss' + +import { LemonLabel, LemonSelect } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' + +import { dataNodeLogic } from '../../DataNode/dataNodeLogic' +import { dataVisualizationLogic } from '../dataVisualizationLogic' + +export const ChartSelection = (): JSX.Element => { + const { columns, selectedXIndex, selectedYIndex } = useValues(dataVisualizationLogic) + const { responseLoading } = useValues(dataNodeLogic) + const { setXAxis, setYAxis } = useActions(dataVisualizationLogic) + + const options = columns.map(({ name, type }) => ({ + value: name, + label: `${name} - ${type}`, + })) + + return ( +
+
+ X-axis + { + const index = options.findIndex((n) => n.value === value) + setXAxis(index) + }} + /> + Y-axis + { + const index = options.findIndex((n) => n.value === value) + setYAxis(index) + }} + /> +
+
+ ) +} diff --git a/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.scss b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.scss new file mode 100644 index 0000000000000..8fdeb47fd4d7f --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.scss @@ -0,0 +1,3 @@ +.DataVisualization__LineGraph { + min-height: var(--viz-min-height); +} diff --git a/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx new file mode 100644 index 0000000000000..fda7661b4d937 --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx @@ -0,0 +1,147 @@ +import 'chartjs-adapter-dayjs-3' +import './LineGraph.scss' + +import { ChartData, Color, GridLineOptions, TickOptions } from 'chart.js' +import ChartDataLabels from 'chartjs-plugin-datalabels' +import { useMountedLogic, useValues } from 'kea' +import { Chart, ChartItem, ChartOptions } from 'lib/Chart' +import { getGraphColors } from 'lib/colors' +import { useEffect, useRef } from 'react' + +import { themeLogic } from '~/layout/navigation-3000/themeLogic' +import { GraphType } from '~/types' + +import { dataVisualizationLogic } from '../../dataVisualizationLogic' + +export const LineGraph = (): JSX.Element => { + const canvasRef = useRef(null) + const { isDarkModeOn } = useValues(themeLogic) + const colors = getGraphColors(isDarkModeOn) + + const vizLogic = useMountedLogic(dataVisualizationLogic) + const { xData, yData } = useValues(vizLogic) + + useEffect(() => { + if (!xData || !yData) { + return + } + + const data: ChartData = { + labels: xData, + datasets: [ + { + label: 'Dataset 1', + data: yData, + borderColor: 'red', + }, + ], + } + + const tickOptions: Partial = { + color: colors.axisLabel as Color, + font: { + family: '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Roboto", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + size: 12, + weight: '500', + }, + } + + const gridOptions: Partial = { + color: colors.axisLine as Color, + borderColor: colors.axisLine as Color, + tickColor: colors.axisLine as Color, + borderDash: [4, 2], + } + + const options: ChartOptions = { + responsive: true, + maintainAspectRatio: false, + elements: { + line: { + tension: 0, + }, + }, + plugins: { + datalabels: { + color: 'white', + anchor: (context) => { + const datum = context.dataset.data[context.dataIndex] + return typeof datum !== 'number' ? 'end' : datum > 0 ? 'end' : 'start' + }, + backgroundColor: (context) => { + return (context.dataset.borderColor as string) || 'black' + }, + display: () => { + return true + }, + borderWidth: 2, + borderRadius: 4, + borderColor: 'white', + }, + legend: { + display: false, + }, + // @ts-expect-error Types of library are out of date + 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 ?? undefined, + width: 1, + }, + }, + }, + hover: { + mode: 'nearest', + axis: 'x', + intersect: false, + }, + scales: { + x: { + display: true, + beginAtZero: true, + ticks: tickOptions, + grid: { + ...gridOptions, + drawOnChartArea: false, + tickLength: 12, + }, + }, + y: { + display: true, + beginAtZero: true, + stacked: false, + ticks: { + display: true, + ...tickOptions, + precision: 1, + }, + grid: gridOptions, + }, + }, + } + + const newChart = new Chart(canvasRef.current?.getContext('2d') as ChartItem, { + type: GraphType.Line, + data, + options, + plugins: [ChartDataLabels], + }) + return () => newChart.destroy() + }, [xData, yData]) + + return ( +
+
+ +
+
+ ) +} diff --git a/frontend/src/queries/nodes/DataVisualization/Components/TableDisplay.tsx b/frontend/src/queries/nodes/DataVisualization/Components/TableDisplay.tsx new file mode 100644 index 0000000000000..49f4646dd28c1 --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/Components/TableDisplay.tsx @@ -0,0 +1,44 @@ +import { LemonSelect, LemonSelectOptions } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { IconShowChart, IconTableChart } from 'lib/lemon-ui/icons' + +import { ChartDisplayType } from '~/types' + +import { dataVisualizationLogic } from '../dataVisualizationLogic' + +export const TableDisplay = (): JSX.Element => { + const { setVisualizationType } = useActions(dataVisualizationLogic) + const { visualizationType } = useValues(dataVisualizationLogic) + + const options: LemonSelectOptions = [ + { + options: [ + { + value: ChartDisplayType.ActionsTable, + icon: , + label: 'Table', + }, + { + value: ChartDisplayType.ActionsLineGraph, + icon: , + label: 'Line chart', + }, + ], + }, + ] + + return ( + { + setVisualizationType(value) + }} + dropdownPlacement="bottom-end" + optionTooltipPlacement="left" + dropdownMatchSelectWidth={false} + data-attr="chart-filter" + options={options} + size="small" + /> + ) +} diff --git a/frontend/src/queries/nodes/DataVisualization/DataVisualization.tsx b/frontend/src/queries/nodes/DataVisualization/DataVisualization.tsx new file mode 100644 index 0000000000000..4b619e282f4e5 --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/DataVisualization.tsx @@ -0,0 +1,93 @@ +import { LemonDivider } from '@posthog/lemon-ui' +import { BindLogic, useValues } from 'kea' +import { useCallback, useState } from 'react' + +import { AnyResponseType, DataVisualizationNode, HogQLQuery, NodeKind } from '~/queries/schema' +import { QueryContext } from '~/queries/types' +import { ChartDisplayType } from '~/types' + +import { dataNodeLogic, DataNodeLogicProps } from '../DataNode/dataNodeLogic' +import { ElapsedTime } from '../DataNode/ElapsedTime' +import { Reload } from '../DataNode/Reload' +import { DataTable } from '../DataTable/DataTable' +import { HogQLQueryEditor } from '../HogQLQuery/HogQLQueryEditor' +import { Chart } from './Components/Chart' +import { TableDisplay } from './Components/TableDisplay' +import { dataVisualizationLogic, DataVisualizationLogicProps } from './dataVisualizationLogic' + +interface DataTableVisualizationProps { + uniqueKey?: string | number + query: DataVisualizationNode + setQuery?: (query: DataVisualizationNode) => void + context?: QueryContext + /* Cached Results are provided when shared or exported, + the data node logic becomes read only implicitly */ + cachedResults?: AnyResponseType +} + +let uniqueNode = 0 + +export function DataTableVisualization(props: DataTableVisualizationProps): JSX.Element { + const [uniqueNodeKey] = useState(() => uniqueNode++) + const [key] = useState(`DataVisualizationNode.${props.uniqueKey?.toString() ?? uniqueNodeKey}`) + + const dataVisualizationLogicProps: DataVisualizationLogicProps = { + key, + query: props.query, + setQuery: props.setQuery, + cachedResults: props.cachedResults, + } + const builtDataVisualizationLogic = dataVisualizationLogic(dataVisualizationLogicProps) + + const dataNodeLogicProps: DataNodeLogicProps = { + query: props.query.source, + key, + cachedResults: props.cachedResults, + } + + const { query, visualizationType, showEditingUI } = useValues(builtDataVisualizationLogic) + + const setQuerySource = useCallback( + (source: HogQLQuery) => props.setQuery?.({ ...props.query, source }), + [props.setQuery] + ) + + let component: JSX.Element | null = null + if (visualizationType === ChartDisplayType.ActionsTable) { + component = ( + + ) + } else if (visualizationType === ChartDisplayType.ActionsLineGraph) { + component = + } + + return ( + + +
+
+ {showEditingUI && } + +
+
+ + +
+
+ +
+
+ {component} +
+
+
+
+ ) +} diff --git a/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts b/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts new file mode 100644 index 0000000000000..627483ddc2891 --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts @@ -0,0 +1,179 @@ +import { actions, afterMount, connect, kea, listeners, path, props, reducers, selectors } from 'kea' +import { subscriptions } from 'kea-subscriptions' +import { insightSceneLogic } from 'scenes/insights/insightSceneLogic' +import { teamLogic } from 'scenes/teamLogic' + +import { AnyResponseType, DataVisualizationNode } from '~/queries/schema' +import { QueryContext } from '~/queries/types' +import { ChartDisplayType, ItemMode } from '~/types' + +import { dataNodeLogic } from '../DataNode/dataNodeLogic' +import type { dataVisualizationLogicType } from './dataVisualizationLogicType' + +export interface DataVisualizationLogicProps { + key: string + query: DataVisualizationNode + context?: QueryContext + setQuery?: (node: DataVisualizationNode) => void + cachedResults?: AnyResponseType +} + +export const dataVisualizationLogic = kea([ + path(['queries', 'nodes', 'DataVisualization', 'dataVisualizationLogic']), + connect({ + values: [teamLogic, ['currentTeamId'], insightSceneLogic, ['insightMode'], dataNodeLogic, ['response']], + actions: [dataNodeLogic, ['loadDataSuccess']], + }), + props({ query: {} } as DataVisualizationLogicProps), + actions({ + setVisualizationType: (visualizationType: ChartDisplayType) => ({ visualizationType }), + setXAxis: (columnIndex: number) => ({ selectedXAxisColumnIndex: columnIndex }), + setYAxis: (columnIndex: number) => ({ selectedYAxisColumnIndex: columnIndex }), + clearAxis: true, + setQuery: (node: DataVisualizationNode) => ({ node }), + }), + reducers({ + columns: [ + [] as { name: string; type: string }[], + { + loadDataSuccess: (_state, { response }) => { + if (!response) { + return [] + } + + const columns: string[] = response['columns'] + const types: string[][] = response['types'] + + return columns.map((column, index) => { + const type = types[index][1] + return { + name: column, + type, + } + }) + }, + }, + ], + visualizationType: [ + ChartDisplayType.ActionsTable as ChartDisplayType, + { + setVisualizationType: (_, { visualizationType }) => visualizationType, + }, + ], + selectedXIndex: [ + null as number | null, + { + clearAxis: () => null, + setXAxis: (_, { selectedXAxisColumnIndex }) => selectedXAxisColumnIndex, + }, + ], + selectedYIndex: [ + null as number | null, + { + clearAxis: () => null, + setYAxis: (_, { selectedYAxisColumnIndex }) => selectedYAxisColumnIndex, + }, + ], + }), + selectors({ + query: [(_state, props) => [props.query], (query) => query], + showEditingUI: [(state) => [state.insightMode], (insightMode) => insightMode == ItemMode.Edit], + isShowingCachedResults: [ + () => [(_, props) => props.cachedResults ?? null], + (cachedResults: AnyResponseType | null): boolean => !!cachedResults, + ], + yData: [ + (state) => [state.selectedYIndex, state.response], + (yIndex, response): null | number[] => { + if (!response || yIndex === null) { + return null + } + + const data: any[] = response?.['results'] ?? [] + return data.map((n) => { + try { + return parseInt(n[yIndex], 10) + } catch { + return 0 + } + }) + }, + ], + xData: [ + (state) => [state.selectedXIndex, state.response], + (xIndex, response): null | string[] => { + if (!response || xIndex === null) { + return null + } + + const data: any[] = response?.['results'] ?? [] + return data.map((n) => n[xIndex]) + }, + ], + }), + listeners(({ props }) => ({ + setQuery: ({ node }) => { + if (props.setQuery) { + props.setQuery(node) + } + }, + setVisualizationType: ({ visualizationType }) => { + if (props.setQuery) { + props.setQuery({ + ...props.query, + display: visualizationType, + }) + } + }, + setXAxis: ({ selectedXAxisColumnIndex }) => { + if (props.setQuery) { + props.setQuery({ + ...props.query, + chartSettings: { + ...(props.query.chartSettings ?? {}), + xAxisIndex: [selectedXAxisColumnIndex], + }, + }) + } + }, + setYAxis: ({ selectedYAxisColumnIndex }) => { + if (props.setQuery) { + props.setQuery({ + ...props.query, + chartSettings: { + ...(props.query.chartSettings ?? {}), + yAxisIndex: [selectedYAxisColumnIndex], + }, + }) + } + }, + })), + afterMount(({ actions, props }) => { + if (props.query.display) { + actions.setVisualizationType(props.query.display) + } + + if (props.query.chartSettings) { + const { xAxisIndex, yAxisIndex } = props.query.chartSettings + + if (xAxisIndex && xAxisIndex.length) { + actions.setXAxis(xAxisIndex[0]) + } + + if (yAxisIndex && yAxisIndex.length) { + actions.setYAxis(yAxisIndex[0]) + } + } + }), + subscriptions(({ actions }) => ({ + columns: (value, oldValue) => { + if (!oldValue || !oldValue.length) { + return + } + + if (JSON.stringify(value) !== JSON.stringify(oldValue)) { + actions.clearAxis() + } + }, + })), +]) diff --git a/frontend/src/queries/query.ts b/frontend/src/queries/query.ts index fbf01155544c3..6a45e77ed7660 100644 --- a/frontend/src/queries/query.ts +++ b/frontend/src/queries/query.ts @@ -22,6 +22,7 @@ import { queryNodeToFilter } from './nodes/InsightQuery/utils/queryNodeToFilter' import { DataNode, HogQLQuery, HogQLQueryResponse, NodeKind, PersonsNode } from './schema' import { isDataTableNode, + isDataVisualizationNode, isEventsQuery, isHogQLQuery, isInsightQueryNode, @@ -48,6 +49,8 @@ export function queryExportContext( return queryExportContext(query.source, methodOptions, refresh) } else if (isDataTableNode(query)) { return queryExportContext(query.source, methodOptions, refresh) + } else if (isDataVisualizationNode(query)) { + return queryExportContext(query.source, methodOptions, refresh) } else if (isEventsQuery(query) || isPersonsQuery(query)) { return { source: query, @@ -103,7 +106,7 @@ async function executeQuery( queryId?: string ): Promise> { const queryAsyncEnabled = Boolean(featureFlagLogic.findMounted()?.values.featureFlags?.[FEATURE_FLAGS.QUERY_ASYNC]) - const excludedKinds = ['HogQLMetadata', 'EventsQuery'] + const excludedKinds = ['HogQLMetadata', 'EventsQuery', 'DataVisualizationNode'] const queryAsync = queryAsyncEnabled && !excludedKinds.includes(queryNode.kind) const response = await api.query(queryNode, methodOptions, queryId, refresh, queryAsync) diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 7d5f3cb358ffc..673c50177c4aa 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -505,6 +505,41 @@ "required": ["kind", "source"], "type": "object" }, + "DataVisualizationNode": { + "additionalProperties": false, + "properties": { + "chartSettings": { + "additionalProperties": false, + "properties": { + "xAxisIndex": { + "items": { + "type": "number" + }, + "type": "array" + }, + "yAxisIndex": { + "items": { + "type": "number" + }, + "type": "array" + } + }, + "type": "object" + }, + "display": { + "$ref": "#/definitions/ChartDisplayType" + }, + "kind": { + "const": "DataVisualizationNode", + "type": "string" + }, + "source": { + "$ref": "#/definitions/HogQLQuery" + } + }, + "required": ["kind", "source"], + "type": "object" + }, "DatabaseSchemaQuery": { "additionalProperties": false, "properties": { @@ -1934,6 +1969,7 @@ "PersonsQuery", "SessionsTimelineQuery", "DataTableNode", + "DataVisualizationNode", "SavedInsightNode", "InsightVizNode", "TrendsQuery", @@ -2378,6 +2414,9 @@ { "$ref": "#/definitions/AnyDataNode" }, + { + "$ref": "#/definitions/DataVisualizationNode" + }, { "$ref": "#/definitions/DataTableNode" }, diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 1f035b9d1acba..fba21f8bc1d53 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -51,6 +51,7 @@ export enum NodeKind { // Interface nodes DataTableNode = 'DataTableNode', + DataVisualizationNode = 'DataVisualizationNode', SavedInsightNode = 'SavedInsightNode', InsightVizNode = 'InsightVizNode', @@ -98,6 +99,7 @@ export type QuerySchema = | AnyDataNode // Interface nodes + | DataVisualizationNode | DataTableNode | SavedInsightNode | InsightVizNode @@ -343,6 +345,18 @@ export interface DataTableNode extends Node, DataTableNodeViewProps { hiddenColumns?: HogQLExpression[] } +interface ChartSettings { + xAxisIndex?: number[] + yAxisIndex?: number[] +} + +export interface DataVisualizationNode extends Node { + kind: NodeKind.DataVisualizationNode + source: HogQLQuery + display?: ChartDisplayType + chartSettings?: ChartSettings +} + interface DataTableNodeViewProps { /** Show with most visual options enabled. Used in scenes. */ full?: boolean /** Include an event filter above the table (EventsNode only) */ diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts index a7a4b527fccac..b7e230577785f 100644 --- a/frontend/src/queries/utils.ts +++ b/frontend/src/queries/utils.ts @@ -6,6 +6,7 @@ import { ActionsNode, DatabaseSchemaQuery, DataTableNode, + DataVisualizationNode, DateRange, EventsNode, EventsQuery, @@ -67,6 +68,7 @@ export function isNodeWithSource( return ( isDataTableNode(node) || + isDataVisualizationNode(node) || isInsightVizNode(node) || isTimeToSeeDataWaterfallNode(node) || isTimeToSeeDataJSONNode(node) @@ -97,6 +99,10 @@ export function isDataTableNode(node?: Node | null): node is DataTableNode { return node?.kind === NodeKind.DataTableNode } +export function isDataVisualizationNode(node?: Node | null): node is DataVisualizationNode { + return node?.kind === NodeKind.DataVisualizationNode +} + export function isSavedInsightNode(node?: Node | null): node is SavedInsightNode { return node?.kind === NodeKind.SavedInsightNode } diff --git a/frontend/src/scenes/insights/InsightNav/InsightsNav.tsx b/frontend/src/scenes/insights/InsightNav/InsightsNav.tsx index 2f21d90494199..b78b2ad1f97df 100644 --- a/frontend/src/scenes/insights/InsightNav/InsightsNav.tsx +++ b/frontend/src/scenes/insights/InsightNav/InsightsNav.tsx @@ -1,7 +1,9 @@ import { useActions, useValues } from 'kea' +import { FEATURE_FLAGS } from 'lib/constants' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import { Link } from 'lib/lemon-ui/Link' import { Tooltip } from 'lib/lemon-ui/Tooltip' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { insightNavLogic } from 'scenes/insights/InsightNav/insightNavLogic' import { insightTypeURL } from 'scenes/insights/utils' import { INSIGHT_TYPES_METADATA } from 'scenes/saved-insights/SavedInsights' @@ -14,6 +16,10 @@ export function InsightsNav(): JSX.Element { const { activeView, tabs } = useValues(insightNavLogic(insightProps)) const { setActiveView } = useActions(insightNavLogic(insightProps)) + const insightTypeUrls = insightTypeURL( + Boolean(featureFlagLogic.findMounted()?.values.featureFlags?.[FEATURE_FLAGS.BI_VIZ]) + ) + return ( <> @@ -23,7 +29,7 @@ export function InsightsNav(): JSX.Element { tabs={tabs.map(({ label, type, dataAttr }) => ({ key: type, label: ( - + {label} diff --git a/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx b/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx index 3d9936869b22e..45738f06c0e44 100644 --- a/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx +++ b/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx @@ -1,4 +1,5 @@ import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { FEATURE_FLAGS } from 'lib/constants' import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { insightDataLogic, queryFromKind } from 'scenes/insights/insightDataLogic' @@ -209,7 +210,8 @@ export const insightNavLogic = kea([ if (view === InsightType.JSON) { actions.setQuery(TotalEventsTable) } else if (view === InsightType.SQL) { - actions.setQuery(examples.HogQLTable) + const biVizFlag = Boolean(values.featureFlags[FEATURE_FLAGS.BI_VIZ]) + actions.setQuery(biVizFlag ? examples.DataVisualization : examples.HogQLTable) } } else { let query: InsightVizNode diff --git a/frontend/src/scenes/insights/utils.tsx b/frontend/src/scenes/insights/utils.tsx index 35e2b04b251f8..2bd9869a10dfc 100644 --- a/frontend/src/scenes/insights/utils.tsx +++ b/frontend/src/scenes/insights/utils.tsx @@ -278,7 +278,7 @@ export function getResponseBytes(apiResponse: Response): number { return parseInt(apiResponse.headers.get('Content-Length') ?? '0') } -export const insightTypeURL: Record = { +export const insightTypeURL = (bi_viz_flag: boolean): Record => ({ TRENDS: urls.insightNew({ insight: InsightType.TRENDS }), STICKINESS: urls.insightNew({ insight: InsightType.STICKINESS }), LIFECYCLE: urls.insightNew({ insight: InsightType.LIFECYCLE }), @@ -286,8 +286,12 @@ export const insightTypeURL: Record = { RETENTION: urls.insightNew({ insight: InsightType.RETENTION }), PATHS: urls.insightNew({ insight: InsightType.PATHS }), JSON: urls.insightNew(undefined, undefined, JSON.stringify(examples.EventsTableFull)), - SQL: urls.insightNew(undefined, undefined, JSON.stringify(examples.HogQLTable)), -} + SQL: urls.insightNew( + undefined, + undefined, + JSON.stringify(bi_viz_flag ? examples.DataVisualization : examples.HogQLTable) + ), +}) /** Combines a list of words, separating with the correct punctuation. For example: [a, b, c, d] -> "a, b, c, and d" */ export function concatWithPunctuation(phrases: string[]): string { diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx index 8b5efc9cfe5f5..239a089cbabbf 100644 --- a/frontend/src/scenes/saved-insights/SavedInsights.tsx +++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx @@ -205,6 +205,12 @@ export const QUERY_TYPES_METADATA: Record = { icon: IconTableChart, inMenu: true, }, + [NodeKind.DataVisualizationNode]: { + name: 'Data visualization', + description: 'Slice and dice your data in a table or chart', + icon: IconTableChart, + inMenu: false, + }, [NodeKind.SavedInsightNode]: { name: 'Insight visualization by short id', description: 'View your insights', diff --git a/frontend/src/scenes/saved-insights/newInsightsMenu.tsx b/frontend/src/scenes/saved-insights/newInsightsMenu.tsx index e7dee45af8d99..8321f478fa594 100644 --- a/frontend/src/scenes/saved-insights/newInsightsMenu.tsx +++ b/frontend/src/scenes/saved-insights/newInsightsMenu.tsx @@ -1,4 +1,6 @@ +import { FEATURE_FLAGS } from 'lib/constants' import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { ReactNode } from 'react' import { insightTypeURL } from 'scenes/insights/utils' @@ -13,6 +15,11 @@ function insightTypesForMenu(): [string, InsightTypeMetadata][] { export function overlayForNewInsightMenu(dataAttr: string): ReactNode[] { const menuEntries = insightTypesForMenu() + + const insightTypeUrls = insightTypeURL( + Boolean(featureFlagLogic.findMounted()?.values.featureFlags?.[FEATURE_FLAGS.BI_VIZ]) + ) + return menuEntries.map( ([listedInsightType, listedInsightTypeMetadata]) => listedInsightTypeMetadata.inMenu && ( @@ -24,7 +31,7 @@ export function overlayForNewInsightMenu(dataAttr: string): ReactNode[] { ) } - to={insightTypeURL[listedInsightType as InsightType]} + to={insightTypeUrls[listedInsightType as InsightType]} data-attr={dataAttr} data-attr-insight-type={listedInsightType} onClick={() => { diff --git a/posthog/schema.py b/posthog/schema.py index 46d107122cd8e..5fe5a8ab37c70 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -87,6 +87,14 @@ class CountPerActorMathType(str, Enum): p99_count_per_actor = "p99_count_per_actor" +class ChartSettings(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + xAxisIndex: Optional[List[float]] = None + yAxisIndex: Optional[List[float]] = None + + class DatabaseSchemaQueryResponseField(BaseModel): model_config = ConfigDict( extra="forbid", @@ -339,6 +347,7 @@ class NodeKind(str, Enum): PersonsQuery = "PersonsQuery" SessionsTimelineQuery = "SessionsTimelineQuery" DataTableNode = "DataTableNode" + DataVisualizationNode = "DataVisualizationNode" SavedInsightNode = "SavedInsightNode" InsightVizNode = "InsightVizNode" TrendsQuery = "TrendsQuery" @@ -1441,6 +1450,16 @@ class ActionsNode(BaseModel): response: Optional[Dict[str, Any]] = Field(default=None, description="Cached query response") +class DataVisualizationNode(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + chartSettings: Optional[ChartSettings] = None + display: Optional[ChartDisplayType] = None + kind: Literal["DataVisualizationNode"] = "DataVisualizationNode" + source: HogQLQuery + + class HasPropertiesNode(RootModel): root: Union[EventsNode, EventsQuery, PersonsNode] @@ -1901,6 +1920,7 @@ class DataTableNode(BaseModel): class QuerySchema(RootModel): root: Union[ + DataVisualizationNode, DataTableNode, SavedInsightNode, InsightVizNode,