@@ -46,17 +57,17 @@ function GroupCaption({ groupData, groupTypeName }: { groupData: IGroup; groupTy
export function Group(): JSX.Element {
const {
+ logicProps,
groupData,
groupDataLoading,
groupTypeName,
- groupKey,
- groupTypeIndex,
groupType,
groupTab,
groupEventsQuery,
showCustomerSuccessDashboards,
} = useValues(groupLogic)
- const { setGroupTab, setGroupEventsQuery } = useActions(groupLogic)
+ const { groupKey, groupTypeIndex } = logicProps
+ const { setGroupEventsQuery } = useActions(groupLogic)
if (!groupData) {
return groupDataLoading ?
:
@@ -67,10 +78,22 @@ export function Group(): JSX.Element {
}
+ buttons={
+
+ }
/>
setGroupTab(tab)}
+ onChange={(tab) => router.actions.push(urls.group(String(groupTypeIndex), groupKey, true, tab))}
tabs={[
{
key: PersonsTabType.PROPERTIES,
diff --git a/frontend/src/scenes/groups/groupLogic.ts b/frontend/src/scenes/groups/groupLogic.ts
index 42fe4904c4867..0800a07890f14 100644
--- a/frontend/src/scenes/groups/groupLogic.ts
+++ b/frontend/src/scenes/groups/groupLogic.ts
@@ -1,4 +1,4 @@
-import { kea } from 'kea'
+import { actions, afterMount, connect, kea, key, path, props, reducers, selectors } from 'kea'
import api from 'lib/api'
import { toParams } from 'lib/utils'
import { teamLogic } from 'scenes/teamLogic'
@@ -13,6 +13,8 @@ import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils'
import { isDataTableNode } from '~/queries/utils'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { FEATURE_FLAGS } from 'lib/constants'
+import { loaders } from 'kea-loaders'
+import { urlToAction } from 'kea-router'
function getGroupEventsQuery(groupTypeIndex: number, groupKey: string): DataTableNode {
return {
@@ -34,9 +36,16 @@ function getGroupEventsQuery(groupTypeIndex: number, groupKey: string): DataTabl
}
}
-export const groupLogic = kea({
- path: ['groups', 'groupLogic'],
- connect: {
+export type GroupLogicProps = {
+ groupTypeIndex: number
+ groupKey: string
+}
+
+export const groupLogic = kea([
+ props({} as GroupLogicProps),
+ key((props) => `${props.groupTypeIndex}-${props.groupKey}`),
+ path((key) => ['scenes', 'groups', 'groupLogic', key]),
+ connect({
values: [
teamLogic,
['currentTeamId'],
@@ -45,71 +54,54 @@ export const groupLogic = kea({
featureFlagLogic,
['featureFlags'],
],
- },
- actions: () => ({
- setGroup: (groupTypeIndex: number, groupKey: string, groupTab?: string | null) => ({
- groupTypeIndex,
- groupKey,
- groupTab,
- }),
+ }),
+ actions(() => ({
setGroupTab: (groupTab: string | null) => ({ groupTab }),
setGroupEventsQuery: (query: Node) => ({ query }),
- }),
- loaders: ({ values }) => ({
+ })),
+ loaders(({ values, props }) => ({
groupData: [
null as Group | null,
{
loadGroup: async () => {
- const params = { group_type_index: values.groupTypeIndex, group_key: values.groupKey }
+ const params = { group_type_index: props.groupTypeIndex, group_key: props.groupKey }
const url = `api/projects/${values.currentTeamId}/groups/find?${toParams(params)}`
return await api.get(url)
},
},
],
- }),
- reducers: {
- groupTypeIndex: [
- 0,
- {
- setGroup: (_, { groupTypeIndex }) => groupTypeIndex,
- },
- ],
- groupKey: [
- '',
- {
- setGroup: (_, { groupKey }) => groupKey,
- },
- ],
+ })),
+ reducers({
groupTab: [
null as string | null,
{
- setGroup: (_, { groupTab }) => groupTab ?? null,
setGroupTab: (_, { groupTab }) => groupTab,
},
],
groupEventsQuery: [
null as DataTableNode | null,
{
- setGroup: (_, { groupTypeIndex, groupKey }) => getGroupEventsQuery(groupTypeIndex, groupKey),
setGroupEventsQuery: (_, { query }) => (isDataTableNode(query) ? query : null),
},
],
- },
- selectors: {
+ }),
+ selectors({
+ logicProps: [() => [(_, props) => props], (props): GroupLogicProps => props],
+
showCustomerSuccessDashboards: [
(s) => [s.featureFlags],
(featureFlags) => featureFlags[FEATURE_FLAGS.CS_DASHBOARDS],
],
groupTypeName: [
- (s) => [s.aggregationLabel, s.groupTypeIndex],
+ (s, p) => [s.aggregationLabel, p.groupTypeIndex],
(aggregationLabel, index): string => aggregationLabel(index).singular,
],
groupType: [
- (s) => [s.groupTypes, s.groupTypeIndex],
+ (s, p) => [s.groupTypes, p.groupTypeIndex],
(groupTypes, index): string => groupTypes[index]?.group_type,
],
breadcrumbs: [
- (s) => [s.groupTypeName, s.groupTypeIndex, s.groupKey, s.groupData],
+ (s, p) => [s.groupTypeName, p.groupTypeIndex, p.groupKey, s.groupData],
(groupTypeName, groupTypeIndex, groupKey, groupData): Breadcrumb[] => [
{
name: capitalizeFirstLetter(groupTypeName),
@@ -121,36 +113,15 @@ export const groupLogic = kea({
},
],
],
- },
- actionToUrl: ({ values }) => ({
- setGroup: () => {
- const { groupTypeIndex, groupKey, groupTab } = values
- return urls.group(String(groupTypeIndex), groupKey, true, groupTab)
- },
- setGroupTab: () => {
- const { groupTypeIndex, groupKey, groupTab } = values
- return urls.group(String(groupTypeIndex), groupKey, true, groupTab)
- },
- }),
- urlToAction: ({ actions, values }) => ({
- '/groups/:groupTypeIndex/:groupKey(/:groupTab)': ({ groupTypeIndex, groupKey, groupTab }) => {
- if (groupTypeIndex && groupKey) {
- if (+groupTypeIndex === values.groupTypeIndex && groupKey === values.groupKey) {
- actions.setGroupTab(groupTab || null)
- } else {
- actions.setGroup(+groupTypeIndex, decodeURIComponent(groupKey), groupTab)
- }
- }
- },
}),
- listeners: ({ actions, selectors, values }) => ({
- setGroup: (_, __, ___, previousState) => {
- if (
- selectors.groupTypeIndex(previousState) !== values.groupTypeIndex ||
- selectors.groupKey(previousState) !== values.groupKey
- ) {
- actions.loadGroup()
- }
+ urlToAction(({ actions }) => ({
+ '/groups/:groupTypeIndex/:groupKey(/:groupTab)': ({ groupTab }) => {
+ actions.setGroupTab(groupTab || null)
},
+ })),
+
+ afterMount(({ actions, props }) => {
+ actions.loadGroup()
+ actions.setGroupEventsQuery(getGroupEventsQuery(props.groupTypeIndex, props.groupKey))
}),
-})
+])
diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodeCohort.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodeCohort.tsx
new file mode 100644
index 0000000000000..82f7800f6ccf9
--- /dev/null
+++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodeCohort.tsx
@@ -0,0 +1,181 @@
+import { createPostHogWidgetNode } from 'scenes/notebooks/Nodes/NodeWrapper'
+import { NotebookNodeType, PropertyFilterType } from '~/types'
+import { useActions, useValues } from 'kea'
+import { urls } from 'scenes/urls'
+import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton'
+import { notebookNodeLogic } from './notebookNodeLogic'
+import { NotebookNodeProps } from '../Notebook/utils'
+import { useEffect, useMemo } from 'react'
+import clsx from 'clsx'
+import { NotFound } from 'lib/components/NotFound'
+import { cohortEditLogic } from 'scenes/cohorts/cohortEditLogic'
+import { IconCohort, IconPerson, InsightsTrendsIcon } from 'lib/lemon-ui/icons'
+import { Query } from '~/queries/Query/Query'
+import { LemonDivider, LemonTag } from '@posthog/lemon-ui'
+import { DataTableNode, NodeKind } from '~/queries/schema'
+
+const Component = ({ attributes, updateAttributes }: NotebookNodeProps): JSX.Element => {
+ const { id } = attributes
+
+ const { expanded } = useValues(notebookNodeLogic)
+ const { setExpanded, setActions, insertAfter } = useActions(notebookNodeLogic)
+
+ const { cohort, cohortLoading, cohortMissing, query } = useValues(cohortEditLogic({ id }))
+ const { setQuery } = useActions(cohortEditLogic({ id }))
+
+ const title = cohort ? `Cohort: ${cohort.name}` : 'Cohort'
+
+ const modifiedQuery = useMemo(() => {
+ return {
+ ...query,
+ embedded: true,
+ // TODO: Add back in controls in a way that actually works - maybe sync with NotebookNodeQuery
+ full: false,
+ showElapsedTime: false,
+ showTimings: false,
+ showOpenEditorButton: false,
+ }
+ }, [query])
+
+ useEffect(() => {
+ updateAttributes({
+ title,
+ })
+ setActions(
+ !cohortMissing
+ ? [
+ {
+ text: 'People in cohort',
+ icon: ,
+ onClick: () => {
+ setExpanded(false)
+ insertAfter({
+ type: NotebookNodeType.Query,
+ attrs: {
+ title: `People in cohort ${cohort.name}`,
+ query: {
+ kind: NodeKind.DataTableNode,
+ source: {
+ kind: NodeKind.PersonsQuery,
+ properties: [
+ {
+ type: PropertyFilterType.Cohort,
+ key: 'id',
+ value: id,
+ },
+ ],
+ },
+ full: true,
+ },
+ },
+ })
+ },
+ },
+
+ {
+ text: 'Cohort trends',
+ icon: ,
+ onClick: () => {
+ setExpanded(false)
+ insertAfter({
+ type: NotebookNodeType.Query,
+ attrs: {
+ title: `Pageviews for cohort '${cohort.name}'`,
+
+ query: {
+ kind: 'InsightVizNode',
+ source: {
+ kind: 'TrendsQuery',
+ filterTestAccounts: true,
+ series: [
+ {
+ kind: 'EventsNode',
+ event: '$pageview',
+ name: '$pageview',
+ math: 'total',
+ },
+ ],
+ interval: 'day',
+ trendsFilter: {
+ display: 'ActionsLineGraph',
+ },
+ properties: {
+ type: 'AND',
+ values: [
+ {
+ type: 'AND',
+ values: [
+ {
+ key: 'id',
+ value: id,
+ type: 'cohort',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+ },
+ })
+ },
+ },
+ ]
+ : []
+ )
+ }, [cohort, cohortMissing])
+
+ if (cohortMissing) {
+ return
+ }
+ return (
+
+
+ {cohortLoading ? (
+
+ ) : (
+
+
+ {cohort.name}
+ ({cohort.count} persons)
+ {cohort.is_static ? 'Static' : 'Dynamic'}
+
+ )}
+
+
+ {expanded ? (
+ <>
+
+
+ >
+ ) : null}
+
+ )
+}
+
+type NotebookNodeCohortAttributes = {
+ id: number
+}
+
+export const NotebookNodeCohort = createPostHogWidgetNode({
+ nodeType: NotebookNodeType.Cohort,
+ defaultTitle: 'Cohort',
+ Component,
+ heightEstimate: 300,
+ minHeight: 100,
+ href: (attrs) => urls.cohort(attrs.id),
+ attributes: {
+ id: {},
+ },
+ pasteOptions: {
+ find: urls.cohort('(.+)'),
+ getAttributes: async (match) => {
+ return { id: parseInt(match[1]) }
+ },
+ },
+ serializedText: (attrs) => {
+ const title = attrs?.title || ''
+ const id = attrs?.id || ''
+ return `${title} ${id}`.trim()
+ },
+})
diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodeGroup.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodeGroup.tsx
new file mode 100644
index 0000000000000..d2278f9b714e3
--- /dev/null
+++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodeGroup.tsx
@@ -0,0 +1,113 @@
+import { createPostHogWidgetNode } from 'scenes/notebooks/Nodes/NodeWrapper'
+import { NotebookNodeType, PropertyFilterType, PropertyOperator } from '~/types'
+import { useActions, useValues } from 'kea'
+import { urls } from 'scenes/urls'
+import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton'
+import { notebookNodeLogic } from './notebookNodeLogic'
+import { NotebookNodeProps } from '../Notebook/utils'
+import { useEffect } from 'react'
+import clsx from 'clsx'
+import { NotFound } from 'lib/components/NotFound'
+import { groupLogic } from 'scenes/groups/groupLogic'
+import { groupDisplayId } from 'scenes/persons/GroupActorDisplay'
+import { GroupCaption } from 'scenes/groups/Group'
+import { NodeKind } from '~/queries/schema'
+import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils'
+
+const Component = ({ attributes, updateAttributes }: NotebookNodeProps): JSX.Element => {
+ const { id, groupTypeIndex } = attributes
+
+ const logic = groupLogic({ groupKey: id, groupTypeIndex: groupTypeIndex })
+ const { groupData, groupDataLoading, groupTypeName } = useValues(logic)
+ const { setActions, insertAfter } = useActions(notebookNodeLogic)
+
+ const groupDisplay = groupData ? groupDisplayId(groupData.group_key, groupData.group_properties) : 'Group'
+ const title = groupData ? `${groupTypeName}: ${groupDisplay}` : 'Group'
+
+ useEffect(() => {
+ updateAttributes({
+ title,
+ })
+ setActions([
+ {
+ text: 'Events for this group',
+ onClick: () => {
+ insertAfter({
+ type: NotebookNodeType.Query,
+ attrs: {
+ title: `Events for ${title}`,
+ query: {
+ kind: NodeKind.DataTableNode,
+ full: true,
+ source: {
+ kind: NodeKind.EventsQuery,
+ select: defaultDataTableColumns(NodeKind.EventsQuery),
+ after: '-24h',
+ properties: [
+ {
+ key: `$group_${groupTypeIndex}`,
+ value: id,
+ type: PropertyFilterType.Event,
+ operator: PropertyOperator.Exact,
+ },
+ ],
+ },
+ },
+ },
+ })
+ },
+ },
+ ])
+ }, [groupData])
+
+ if (!groupData && !groupDataLoading) {
+ return
+ }
+
+ return (
+
+
+ {groupDataLoading ? (
+
+ ) : groupData ? (
+ <>
+
{groupDisplay}
+
+ >
+ ) : null}
+
+
+ )
+}
+
+type NotebookNodeGroupAttributes = {
+ id: string
+ groupTypeIndex: number
+}
+
+export const NotebookNodeGroup = createPostHogWidgetNode({
+ nodeType: NotebookNodeType.Group,
+ defaultTitle: 'Group',
+ Component,
+ heightEstimate: 300,
+ minHeight: 100,
+ href: (attrs) => urls.group(attrs.groupTypeIndex, attrs.id),
+ resizeable: false,
+ expandable: false,
+ attributes: {
+ id: {},
+ groupTypeIndex: {},
+ },
+ pasteOptions: {
+ find: urls.groups('(.+)'),
+ getAttributes: async (match) => {
+ const [groupTypeIndex, id] = match[1].split('/')
+ return { id: decodeURIComponent(id), groupTypeIndex: parseInt(groupTypeIndex) }
+ },
+ },
+ serializedText: (attrs) => {
+ const title = attrs?.title || ''
+ const id = attrs?.id || ''
+ return `${title} ${id}`.trim()
+ },
+})
diff --git a/frontend/src/scenes/notebooks/Notebook/Editor.tsx b/frontend/src/scenes/notebooks/Notebook/Editor.tsx
index c05cf3c31cb42..39c6c29115958 100644
--- a/frontend/src/scenes/notebooks/Notebook/Editor.tsx
+++ b/frontend/src/scenes/notebooks/Notebook/Editor.tsx
@@ -34,6 +34,8 @@ import { InlineMenu } from './InlineMenu'
import NodeGapInsertionExtension from './Extensions/NodeGapInsertion'
import { notebookLogic } from './notebookLogic'
import { sampleOne } from 'lib/utils'
+import { NotebookNodeGroup } from '../Nodes/NotebookNodeGroup'
+import { NotebookNodeCohort } from '../Nodes/NotebookNodeCohort'
const CustomDocument = ExtensionDocument.extend({
content: 'heading block*',
@@ -99,6 +101,8 @@ export function Editor(): JSX.Element {
NotebookNodeReplayTimestamp,
NotebookNodePlaylist,
NotebookNodePerson,
+ NotebookNodeCohort,
+ NotebookNodeGroup,
NotebookNodeFlagCodeExample,
NotebookNodeFlag,
NotebookNodeExperiment,
diff --git a/frontend/src/scenes/notebooks/Notebook/utils.ts b/frontend/src/scenes/notebooks/Notebook/utils.ts
index 2684779d9d909..c581946030807 100644
--- a/frontend/src/scenes/notebooks/Notebook/utils.ts
+++ b/frontend/src/scenes/notebooks/Notebook/utils.ts
@@ -116,6 +116,8 @@ export const textContent = (node: any): string => {
'ph-recording-playlist': customOrTitleSerializer,
'ph-replay-timestamp': customOrTitleSerializer,
'ph-survey': customOrTitleSerializer,
+ 'ph-group': customOrTitleSerializer,
+ 'ph-cohort': customOrTitleSerializer,
}
return getText(node, {
diff --git a/frontend/src/scenes/notebooks/NotebooksTable/ContainsTypeFilter.tsx b/frontend/src/scenes/notebooks/NotebooksTable/ContainsTypeFilter.tsx
index d461709133db2..dc0c082733816 100644
--- a/frontend/src/scenes/notebooks/NotebooksTable/ContainsTypeFilter.tsx
+++ b/frontend/src/scenes/notebooks/NotebooksTable/ContainsTypeFilter.tsx
@@ -14,6 +14,8 @@ export const fromNodeTypeToLabel: Omit, Noteboo
[NotebookNodeType.Recording]: 'Session recordings',
[NotebookNodeType.RecordingPlaylist]: 'Session replay playlists',
[NotebookNodeType.ReplayTimestamp]: 'Session recording comments',
+ [NotebookNodeType.Cohort]: 'Cohorts',
+ [NotebookNodeType.Group]: 'Groups',
}
export function ContainsTypeFilters({
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 7711bbd99622f..a4ffe5f829dc3 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -3071,6 +3071,8 @@ export enum NotebookNodeType {
EarlyAccessFeature = 'ph-early-access-feature',
Survey = 'ph-survey',
Person = 'ph-person',
+ Group = 'ph-group',
+ Cohort = 'ph-cohort',
Backlink = 'ph-backlink',
ReplayTimestamp = 'ph-replay-timestamp',
Image = 'ph-image',