diff --git a/frontend/src/lib/components/PropertySelect/PropertySelect.tsx b/frontend/src/lib/components/PropertySelect/PropertySelect.tsx index c3c80e0dfc7ee..df3b1ffeffa2a 100644 --- a/frontend/src/lib/components/PropertySelect/PropertySelect.tsx +++ b/frontend/src/lib/components/PropertySelect/PropertySelect.tsx @@ -16,7 +16,7 @@ export interface PropertySelectProps { onChange: (names: string[]) => void selectedProperties: string[] sortable?: boolean - taxonomicFilterGroup: TaxonomicFilterGroupType.PersonProperties | TaxonomicFilterGroupType.EventProperties + taxonomicFilterGroup: TaxonomicFilterGroupType } const SortableProperty = ({ diff --git a/frontend/src/scenes/data-warehouse/ViewLinkModal.tsx b/frontend/src/scenes/data-warehouse/ViewLinkModal.tsx index d6f9e9f083146..50a2071b780ec 100644 --- a/frontend/src/scenes/data-warehouse/ViewLinkModal.tsx +++ b/frontend/src/scenes/data-warehouse/ViewLinkModal.tsx @@ -211,7 +211,7 @@ export function ViewLinkForm(): JSX.Element { ) } -const HogQLDropdown = ({ +export const HogQLDropdown = ({ hogQLValue, onHogQLValueChange, tableName, diff --git a/frontend/src/scenes/data-warehouse/viewLinkLogic.tsx b/frontend/src/scenes/data-warehouse/viewLinkLogic.tsx index b55875358c7ed..dee9aad2ab61c 100644 --- a/frontend/src/scenes/data-warehouse/viewLinkLogic.tsx +++ b/frontend/src/scenes/data-warehouse/viewLinkLogic.tsx @@ -213,6 +213,16 @@ export const viewLinkLogic = kea([ label: table.name, })), ], + tableOptionsWarehouseOnly: [ + (s) => [s.allTables], + (tables) => + tables + .filter((t) => t.type != 'posthog') + .map((table) => ({ + value: table.name, + label: table.name, + })), + ], sourceTableKeys: [ (s) => [s.selectedSourceTable], (selectedSourceTable): KeySelectOption[] => { diff --git a/frontend/src/scenes/groups/Group.tsx b/frontend/src/scenes/groups/Group.tsx index 1cf23337da773..6fda8e59e2690 100644 --- a/frontend/src/scenes/groups/Group.tsx +++ b/frontend/src/scenes/groups/Group.tsx @@ -6,11 +6,13 @@ import { PageHeader } from 'lib/components/PageHeader' import { PropertiesTable } from 'lib/components/PropertiesTable' import { TZLabel } from 'lib/components/TZLabel' import { isEventFilter } from 'lib/components/UniversalFilters/utils' +import { FEATURE_FLAGS } from 'lib/constants' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import { lemonToast } from 'lib/lemon-ui/LemonToast' import { Link } from 'lib/lemon-ui/Link' import { Spinner, SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { GroupDashboard } from 'scenes/groups/GroupDashboard' import { groupLogic, GroupLogicProps } from 'scenes/groups/groupLogic' import { RelatedGroups } from 'scenes/groups/RelatedGroups' @@ -86,6 +88,7 @@ export function Group(): JSX.Element { const { groupKey, groupTypeIndex } = logicProps const { setGroupEventsQuery } = useActions(groupLogic) const { currentTeam } = useValues(teamLogic) + const { featureFlags } = useValues(featureFlagLogic) if (!groupData || !groupType) { return groupDataLoading ? : @@ -116,12 +119,21 @@ export function Group(): JSX.Element { key: PersonsTabType.PROPERTIES, label: Properties, content: ( - + <> + +
+ + {featureFlags[FEATURE_FLAGS.CS_DASHBOARDS] && ( + + Import properties from other sources + + )} + ), }, { diff --git a/frontend/src/scenes/groups/Groups.tsx b/frontend/src/scenes/groups/Groups.tsx index 5ada7cd62c73a..0c032b172d9c5 100644 --- a/frontend/src/scenes/groups/Groups.tsx +++ b/frontend/src/scenes/groups/Groups.tsx @@ -1,8 +1,11 @@ +import { LemonButton, LemonTabs } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { router } from 'kea-router' import { PropertiesTable } from 'lib/components/PropertiesTable' import { TZLabel } from 'lib/components/TZLabel' +import { FEATURE_FLAGS } from 'lib/constants' import { groupsAccessLogic, GroupsAccessStatus } from 'lib/introductions/groupsAccessLogic' +import { IconOpenInNew } from 'lib/lemon-ui/icons' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonDivider } from 'lib/lemon-ui/LemonDivider' import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput' @@ -10,16 +13,22 @@ import { LemonTable } from 'lib/lemon-ui/LemonTable' import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' import { LemonTableColumns } from 'lib/lemon-ui/LemonTable/types' import { Link } from 'lib/lemon-ui/Link' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { capitalizeFirstLetter } from 'lib/utils' import { GroupsIntroduction } from 'scenes/groups/GroupsIntroduction' +import { getDefaultEvent } from 'scenes/insights/utils/cleanFilters' import { groupDisplayId } from 'scenes/persons/GroupActorDisplay' import { urls } from 'scenes/urls' +import { Query } from '~/queries/Query/Query' +import { NodeKind } from '~/queries/schema' import { Group, PropertyDefinitionType } from '~/types' +import { GroupsConfiguration, Snippet } from './GroupsConfiguration' import { groupsListLogic } from './groupsListLogic' +import { groupsLogic } from './groupsLogic' -export function Groups({ groupTypeIndex }: { groupTypeIndex: number }): JSX.Element { +export function GroupsTable({ groupTypeIndex }: { groupTypeIndex: number }): JSX.Element { const { groupTypeName: { singular, plural }, groups, @@ -27,24 +36,6 @@ export function Groups({ groupTypeIndex }: { groupTypeIndex: number }): JSX.Elem search, } = useValues(groupsListLogic({ groupTypeIndex })) const { loadGroups, setSearch } = useActions(groupsListLogic({ groupTypeIndex })) - const { groupsAccessStatus } = useValues(groupsAccessLogic) - - if (groupTypeIndex === undefined) { - throw new Error('groupTypeIndex is undefined') - } - - if ( - groupsAccessStatus == GroupsAccessStatus.HasAccess || - groupsAccessStatus == GroupsAccessStatus.HasGroupTypes || - groupsAccessStatus == GroupsAccessStatus.NoAccess - ) { - return ( - <> - - - ) - } - const columns: LemonTableColumns = [ { title: capitalizeFirstLetter(plural), @@ -66,7 +57,6 @@ export function Groups({ groupTypeIndex }: { groupTypeIndex: number }): JSX.Elem }, }, ] - return ( <> Read more here. +
+ - - {`posthog.group('${singular}', 'id:5', {\n` + - ` name: 'Awesome ${singular}',\n` + - ' value: 11\n' + - '});'} - } /> ) } + +export function GroupsOverview({ groupTypeIndex }: { groupTypeIndex: number }): JSX.Element { + const { + groupTypeName: { plural }, + search, + } = useValues(groupsListLogic({ groupTypeIndex })) + const { setSearch } = useActions(groupsListLogic({ groupTypeIndex })) + const onSearch = (): void => { + if (search && search !== '') { + router.actions.push(urls.groups(String(groupTypeIndex), 'list')) + } + } + + const queries = [ + { + title: ( + <> + Top {plural} by {getDefaultEvent().name} + + ), + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + interval: 'week', + filterTestAccounts: false, + series: [ + { + event: getDefaultEvent().id, + kind: NodeKind.EventsNode, + math: 'total', + name: getDefaultEvent().name, + }, + ], + breakdownFilter: { + breakdown_type: 'hogql', + breakdown: `coalesce(group_${groupTypeIndex}.properties.name, $group_0)`, + }, + dateRange: { + date_from: '-30d', + }, + trendsFilter: { + display: 'ActionsTable', + }, + }, + full: false, + showDateRange: true, + }, + }, + { + title: <>Top {plural} by users, + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + interval: 'week', + filterTestAccounts: false, + series: [ + { + event: getDefaultEvent().id, + kind: NodeKind.EventsNode, + math: 'dau', + math_group_type_index: groupTypeIndex, + name: getDefaultEvent().name, + }, + ], + breakdownFilter: { + breakdown_type: 'hogql', + breakdown: `coalesce(group_${groupTypeIndex}.properties.name, $group_0)`, + }, + dateRange: { + date_from: '-30d', + }, + trendsFilter: { + display: 'ActionsTable', + }, + }, + full: false, + showDateRange: true, + }, + }, + { + title: ( + <> + Unique {plural} {getDefaultEvent().name} + + ), + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + interval: 'week', + filterTestAccounts: false, + series: [ + { + event: getDefaultEvent().id, + kind: NodeKind.EventsNode, + math: 'unique_group', + math_group_type_index: groupTypeIndex, + name: getDefaultEvent().name, + }, + ], + dateRange: { + date_from: '-30d', + }, + }, + full: false, + showDateRange: true, + }, + }, + ] + + return ( + <> + + {queries.map(({ title, query }, index) => ( + <> +

{title}

+ +
+ } + size="small" + type="secondary" + > + Open as new Insight + +
+ + ))} + + ) +} + +export function Groups({ groupTypeIndex }: { groupTypeIndex: number }): JSX.Element { + const { groupsAccessStatus } = useValues(groupsAccessLogic) + const { groupTab } = useValues(groupsLogic) + const { featureFlags } = useValues(featureFlagLogic) + + if (groupTypeIndex === undefined) { + throw new Error('groupTypeIndex is undefined') + } + + if ( + groupsAccessStatus == GroupsAccessStatus.HasAccess || + groupsAccessStatus == GroupsAccessStatus.HasGroupTypes || + groupsAccessStatus == GroupsAccessStatus.NoAccess + ) { + return ( + <> + + + ) + } + if (featureFlags[FEATURE_FLAGS.CS_DASHBOARDS]) { + return ( + router.actions.push(urls.groups(String(groupTypeIndex), tab))} + tabs={[ + { + key: 'overview', + label: <>Overview, + content: , + }, + { + key: 'list', + label: <>List, + content: , + }, + { + key: 'config', + label: <>Configuration, + content: , + }, + ]} + /> + ) + } + return +} diff --git a/frontend/src/scenes/groups/GroupsConfiguration.tsx b/frontend/src/scenes/groups/GroupsConfiguration.tsx new file mode 100644 index 0000000000000..ffda16a7f2c8f --- /dev/null +++ b/frontend/src/scenes/groups/GroupsConfiguration.tsx @@ -0,0 +1,341 @@ +import { + LemonButton, + LemonDialog, + LemonDivider, + LemonModal, + LemonSelect, + LemonTable, + LemonTableColumn, +} from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { Field, Form } from 'kea-forms' +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { PropertySelect } from 'lib/components/PropertySelect/PropertySelect' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { IconSwapHoriz } from 'lib/lemon-ui/icons' +import { More } from 'lib/lemon-ui/LemonButton/More' +import { LemonRadio } from 'lib/lemon-ui/LemonRadio' +import { createdAtColumn, createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils' +import { deleteWithUndo } from 'lib/utils/deleteWithUndo' +import { useState } from 'react' +import { DatabaseTable } from 'scenes/data-management/database/DatabaseTable' +import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic' +import { dataWarehouseJoinsLogic } from 'scenes/data-warehouse/external/dataWarehouseJoinsLogic' +import { viewLinkLogic } from 'scenes/data-warehouse/viewLinkLogic' +import { HogQLDropdown } from 'scenes/data-warehouse/ViewLinkModal' +import { teamLogic } from 'scenes/teamLogic' + +import { DataWarehouseViewLink } from '~/types' + +import { groupsConfigurationLogic } from './groupsConfigurationLogic' +import { groupsListLogic } from './groupsListLogic' +export function Snippet({ singular }: { singular: string }): JSX.Element { + return ( + + {`posthog.group('${singular}', 'id:5', {\n` + + ` name: 'Awesome ${singular}',\n` + + ' value: 11\n' + + '});'} + + ) +} + +export function GroupJoinModal({ + groupTypeIndex, + isOpen, + onClose, +}: { + groupTypeIndex: number + isOpen: boolean + onClose: () => void +}): JSX.Element { + const { + groupTypeName: { singular, plural }, + } = useValues(groupsListLogic({ groupTypeIndex })) + const { + tableOptionsWarehouseOnly, + selectedJoiningTableName, + joiningTableKeys, + selectedSourceKey, + selectedJoiningKey, + sourceIsUsingHogQLExpression, + joiningIsUsingHogQLExpression, + isViewLinkSubmitting, + } = useValues(viewLinkLogic) + const { selectJoiningTable, selectSourceKey, selectJoiningKey } = useActions(viewLinkLogic) + + return ( + + Define a join between {plural} and any table or view. All fields from the joined table or + view will be accessible in queries at the top level without needing to explicitly join the view. + + } + isOpen={isOpen} + onClose={onClose} + width={700} + > +
+
+ {/*
*/} + {/*
*/} +
+
+ Joining Table +
{plural}
+
+
+
+
+ Property to join on + + newValue == 'key' && selectSourceKey('key')} + value={!selectedSourceKey || selectedSourceKey === 'key' ? 'key' : 'property'} + options={[ + { value: 'key', label: `${singular} key` }, + { + value: 'property', + label: ( + + selectSourceKey(`properties.\`${property}\``) + } + selectedProperties={ + sourceIsUsingHogQLExpression && selectedSourceKey + ? [selectedSourceKey.replace('property.`', '').slice(0, -1)] + : [] + } + addText="select property" + taxonomicFilterGroup={ + `${TaxonomicFilterGroupType.GroupsPrefix}_${groupTypeIndex}` as TaxonomicFilterGroupType + } + /> + ), + }, + ]} + /> + +
+
+
+ +
+ +
+
+ Joining Table + + + +
+
+ Joining Table Key + + <> + HogQL Expression }, + ]} + placeholder="Select a key" + /> + {joiningIsUsingHogQLExpression && ( + + )} + + +
+
+
+ +
+ + Close + + + Save + +
+ + + ) +} + +function JoinTable({ groupTypeIndex }: { groupTypeIndex: number }): JSX.Element { + const logic = groupsConfigurationLogic({ groupTypeIndex }) + + const { + groupTypeName: { singular }, + } = useValues(groupsListLogic({ groupTypeIndex })) + const { joins } = useValues(logic) + + const { loadJoins } = useActions(dataWarehouseJoinsLogic) + const { loadDatabase } = useActions(databaseTableListLogic) + const { allTables } = useValues(databaseTableListLogic) + + const { currentTeamId } = useValues(teamLogic) + + return joins.length > 0 ? ( + ( + <> + + {singular}.{join.source_table_key} + {' '} + {' '} + + {join.joining_table_name}.{join.joining_table_key} + + + ), + }, + { + title: 'Joined fields', + tooltip: 'All fields that are joined onto this group type', + render: (_, join) => ( + <> + {join.joining_table_name && ( + + )} + + ), + }, + createdAtColumn() as LemonTableColumn, + createdByColumn() as LemonTableColumn, + { + key: 'actions', + width: 0, + render: function RenderActions(_, join) { + return ( +
+ + { + LemonDialog.open({ + title: 'Delete join?', + description: 'Are you sure you want to delete this join?', + + primaryButton: { + children: 'Delete', + status: 'danger', + onClick: () => + void deleteWithUndo({ + endpoint: `projects/${currentTeamId}/warehouse_view_link`, + object: { + id: join.id, + name: `${join.field_name} on ${join.source_table_name}`, + }, + callback: () => { + loadDatabase() + loadJoins() + }, + }), + }, + secondaryButton: { + children: 'Cancel', + }, + }) + }} + > + Delete + + + } + /> +
+ ) + }, + }, + ]} + /> + ) : ( + <> + ) +} + +export function GroupsConfiguration({ groupTypeIndex }: { groupTypeIndex: number }): JSX.Element { + const { + groupTypeName: { singular, plural }, + } = useValues(groupsListLogic({ groupTypeIndex })) + const [isJoinTableModalOpen, toggleJoinTableModal] = useState(false) + + const { dataWarehouseTables } = useValues(databaseTableListLogic) + + return ( + <> +

+

Sending {plural} data

+ Make sure you correctly send group information: + +

+ +

+

Pull in data from other sources

+

+ Using the PostHog Data warehouse, you can add data from Stripe, Postgres, Hubspot, Salesforce and + many other sources onto your {plural}. You can then query or display that data. +

+ +

+

Step 1: add sources to the Data warehouse

+ + Add a data warehouse source + +
+
+ +

Step 2: join the data onto {plural}

+ toggleJoinTableModal(true)} + > + Add a join onto {plural} + +
+
+

+ toggleJoinTableModal(false)} + /> + +

+ + ) +} diff --git a/frontend/src/scenes/groups/groupsConfigurationLogic.ts b/frontend/src/scenes/groups/groupsConfigurationLogic.ts new file mode 100644 index 0000000000000..32b70f2def665 --- /dev/null +++ b/frontend/src/scenes/groups/groupsConfigurationLogic.ts @@ -0,0 +1,54 @@ +import { actions, connect, kea, key, path, props, reducers, selectors } from 'kea' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { dataWarehouseJoinsLogic } from 'scenes/data-warehouse/external/dataWarehouseJoinsLogic' +import { teamLogic } from 'scenes/teamLogic' + +import { groupsModel } from '~/models/groupsModel' +import { Node } from '~/queries/schema' + +import type { groupsConfigurationLogicType } from './groupsConfigurationLogicType' + +export type GroupsConfigurationLogicProps = { + groupTypeIndex: number +} + +export const groupsConfigurationLogic = kea([ + props({} as GroupsConfigurationLogicProps), + key((props) => `${props.groupTypeIndex}`), + path((key) => ['scenes', 'groups', 'groupsConfigurationLogic', key]), + connect({ + values: [ + teamLogic, + ['currentTeamId'], + groupsModel, + ['groupTypes', 'aggregationLabel'], + featureFlagLogic, + ['featureFlags'], + dataWarehouseJoinsLogic, + ['joins'], + ], + actions: [dataWarehouseJoinsLogic, ['loadJoins']], + }), + + actions(() => ({ + setGroupTab: (groupTab: string | null) => ({ groupTab }), + setGroupEventsQuery: (query: Node) => ({ query }), + })), + + reducers({ + groupTab: [ + null as string | null, + { + setGroupTab: (_, { groupTab }) => groupTab, + }, + ], + }), + selectors(({ props }) => ({ + logicProps: [() => [(_, props) => props], (props): GroupsConfigurationLogicProps => props], + + groupJoins: [ + (s) => [s.joins], + (joins) => joins.filter((join) => join.group_type_index === props.groupTypeIndex), + ], + })), +]) diff --git a/frontend/src/scenes/groups/groupsLogic.ts b/frontend/src/scenes/groups/groupsLogic.ts new file mode 100644 index 0000000000000..3cf45898cd1d6 --- /dev/null +++ b/frontend/src/scenes/groups/groupsLogic.ts @@ -0,0 +1,57 @@ +import { actions, connect, kea, key, path, props, reducers, selectors } from 'kea' +import { urlToAction } from 'kea-router' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { teamLogic } from 'scenes/teamLogic' + +import { groupsModel } from '~/models/groupsModel' +import { Node } from '~/queries/schema' + +import type { groupsLogicType } from './groupsLogicType' + +export type GroupLogicProps = { + groupTypeIndex: number + groupKey: string +} + +export const groupsLogic = kea([ + props({} as GroupLogicProps), + key((props) => `${props.groupTypeIndex}`), + path((key) => ['scenes', 'groups', 'groupLogic', key]), + connect({ + values: [ + teamLogic, + ['currentTeamId'], + groupsModel, + ['groupTypes', 'aggregationLabel'], + featureFlagLogic, + ['featureFlags'], + ], + }), + actions(() => ({ + setGroupTab: (groupTab: string | null) => ({ groupTab }), + setGroupEventsQuery: (query: Node) => ({ query }), + })), + + reducers({ + groupTab: [ + null as string | null, + { + setGroupTab: (_, { groupTab }) => groupTab, + }, + ], + }), + selectors({ + logicProps: [() => [(_, props) => props], (props): GroupLogicProps => props], + }), + urlToAction(({ actions }) => ({ + '/groups/:groupTypeIndex/overview': () => { + actions.setGroupTab('overview') + }, + '/groups/:groupTypeIndex/list': () => { + actions.setGroupTab('list') + }, + '/groups/:groupTypeIndex/config': () => { + actions.setGroupTab('config') + }, + })), +]) diff --git a/frontend/src/scenes/persons-management/personsManagementSceneLogic.tsx b/frontend/src/scenes/persons-management/personsManagementSceneLogic.tsx index fc7654dad7c55..d0c420fe26b67 100644 --- a/frontend/src/scenes/persons-management/personsManagementSceneLogic.tsx +++ b/frontend/src/scenes/persons-management/personsManagementSceneLogic.tsx @@ -34,7 +34,7 @@ export const personsManagementSceneLogic = kea( values: [groupsModel, ['aggregationLabel', 'groupTypes', 'groupsAccessStatus', 'aggregationLabel']], }), actions({ - setTabKey: (tabKey: string) => ({ tabKey }), + setTabKey: (tabKey: string, changeUrl?: false | null) => ({ tabKey, changeUrl }), }), reducers({ tabKey: [ @@ -136,20 +136,26 @@ export const personsManagementSceneLogic = kea( ], }), actionToUrl(({ values }) => ({ - setTabKey: ({ tabKey }) => { - return values.tabs.find((x) => x.key === tabKey)?.url || values.tabs[0].url + setTabKey: ({ tabKey, changeUrl }) => { + if (changeUrl === false) { + return + } + return values.tabs.find((x) => x.key === tabKey)?.url }, })), urlToAction(({ actions }) => { return { [urls.persons()]: () => { - actions.setTabKey('persons') + actions.setTabKey('persons', false) }, [urls.cohorts()]: () => { - actions.setTabKey('cohorts') + actions.setTabKey('cohorts', false) }, [urls.groups(':key')]: ({ key }) => { - actions.setTabKey(`groups-${key}`) + actions.setTabKey(`groups-${key}`, false) + }, + [urls.groups(':key', ':tab')]: ({ key }) => { + actions.setTabKey(`groups-${key}`, false) }, } }), diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 7581e424bd2fd..a331168e6360c 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -501,6 +501,9 @@ export const routes: Record = { [urls.pipelineNode(':stage', ':id', ':nodeTab')]: Scene.PipelineNode, [urls.pipelineNode(':stage', ':id')]: Scene.PipelineNode, [urls.groups(':groupTypeIndex')]: Scene.PersonsManagement, + [urls.groups(':groupTypeIndex', 'overview')]: Scene.PersonsManagement, + [urls.groups(':groupTypeIndex', 'list')]: Scene.PersonsManagement, + [urls.groups(':groupTypeIndex', 'config')]: Scene.PersonsManagement, [urls.group(':groupTypeIndex', ':groupKey', false)]: Scene.Group, [urls.group(':groupTypeIndex', ':groupKey', false, ':groupTab')]: Scene.Group, [urls.cohort(':id')]: Scene.Cohort, diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index cc046586fc768..46e449d3e2ba5 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -125,7 +125,8 @@ export const urls = { `/pipeline/${!stage.startsWith(':') && !stage?.endsWith('s') ? `${stage}s` : stage}/${id}${ nodeTab ? `/${nodeTab}` : '' }`, - groups: (groupTypeIndex: string | number): string => `/groups/${groupTypeIndex}`, + groups: (groupTypeIndex: string | number, tab?: string | null): string => + `/groups/${groupTypeIndex}${tab ? `/${tab}` : ''}`, // :TRICKY: Note that groupKey is provided by user. We need to override urlPatternOptions for kea-router. group: (groupTypeIndex: string | number, groupKey: string, encode: boolean = true, tab?: string | null): string => `/groups/${groupTypeIndex}/${encode ? encodeURIComponent(groupKey) : groupKey}${tab ? `/${tab}` : ''}`, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 61d6072f17d21..bdccc41bd7b55 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3845,6 +3845,7 @@ export interface DataWarehouseViewLink { field_name?: string created_by?: UserBasicType | null created_at?: string | null + group_type_index?: number | null } export enum DataWarehouseSettingsTab { diff --git a/latest_migrations.manifest b/latest_migrations.manifest index 54f01686c7aee..37b65cd93c330 100644 --- a/latest_migrations.manifest +++ b/latest_migrations.manifest @@ -5,7 +5,7 @@ contenttypes: 0002_remove_content_type_name ee: 0016_rolemembership_organization_member otp_static: 0002_throttling otp_totp: 0002_auto_20190420_0723 -posthog: 0466_alter_externaldatasource_source_type +posthog: 0467_datawarehousejoin_group_type_index sessions: 0001_initial social_django: 0010_uid_db_index two_factor: 0007_auto_20201201_1019 diff --git a/posthog/hogql/database/test/test_database.py b/posthog/hogql/database/test/test_database.py index 73d86544f5c33..fdd1ce38b3cce 100644 --- a/posthog/hogql/database/test/test_database.py +++ b/posthog/hogql/database/test/test_database.py @@ -306,6 +306,29 @@ def test_database_warehouse_joins_deleted_join(self): with pytest.raises(ExposedHogQLError): print_ast(parse_select(sql), context, dialect="clickhouse") + def test_database_warehouse_joins_group_type_index(self): + GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) + GroupTypeMapping.objects.create(team=self.team, group_type="project", group_type_index=1) + DataWarehouseJoin.objects.create( + team=self.team, + source_table_name="groups", + source_table_key="key", + joining_table_name="events", + joining_table_key="event", + group_type_index=0, + field_name="some_field", + ) + + db = create_hogql_database(team_id=self.team.pk) + context = HogQLContext( + team_id=self.team.pk, + enable_select_queries=True, + database=db, + ) + + sql = "select some_field.event from groups" + self.assertIn("equals(groups.index, 0)", print_ast(parse_select(sql), context, dialect="clickhouse")) + def test_database_warehouse_joins_other_team(self): other_organization = Organization.objects.create(name="some_other_org") other_team = Team.objects.create(organization=other_organization) diff --git a/posthog/migrations/0467_datawarehousejoin_group_type_index.py b/posthog/migrations/0467_datawarehousejoin_group_type_index.py new file mode 100644 index 0000000000000..7e6db1ffaeee3 --- /dev/null +++ b/posthog/migrations/0467_datawarehousejoin_group_type_index.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.14 on 2024-09-04 14:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "0466_alter_externaldatasource_source_type"), + ] + + operations = [ + migrations.AddField( + model_name="datawarehousejoin", + name="group_type_index", + field=models.IntegerField( + blank=True, + help_text="Group type index means the join only happens for that group type, not all groups", + null=True, + ), + ), + ] diff --git a/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr b/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr index 23d8c307578c4..ab0f181d4d730 100644 --- a/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr +++ b/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr @@ -638,7 +638,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -760,7 +761,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -1404,7 +1406,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -1526,7 +1529,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -1676,7 +1680,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -1944,7 +1949,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -2074,7 +2080,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -2538,7 +2545,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -2660,7 +2668,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -3095,7 +3104,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -3273,7 +3283,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -3710,7 +3721,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -3832,7 +3844,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -4088,7 +4101,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -4210,7 +4224,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -4625,7 +4640,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -4759,7 +4775,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -5223,7 +5240,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -5345,7 +5363,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -5756,7 +5775,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -5775,7 +5795,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -5897,7 +5918,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -6304,7 +6326,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" @@ -6426,7 +6449,8 @@ "posthog_datawarehousejoin"."source_table_key", "posthog_datawarehousejoin"."joining_table_name", "posthog_datawarehousejoin"."joining_table_key", - "posthog_datawarehousejoin"."field_name" + "posthog_datawarehousejoin"."field_name", + "posthog_datawarehousejoin"."group_type_index" FROM "posthog_datawarehousejoin" WHERE ("posthog_datawarehousejoin"."team_id" = 2 AND NOT ("posthog_datawarehousejoin"."deleted" diff --git a/posthog/warehouse/api/view_link.py b/posthog/warehouse/api/view_link.py index e3d701bb64b99..18cc2e172ca9b 100644 --- a/posthog/warehouse/api/view_link.py +++ b/posthog/warehouse/api/view_link.py @@ -25,6 +25,7 @@ class Meta: "joining_table_name", "joining_table_key", "field_name", + "group_type_index", ] read_only_fields = ["id", "created_by", "created_at"] @@ -41,6 +42,9 @@ def create(self, validated_data): self._validate_join_key(source_table_key, source_table, self.context["team_id"]) self._validate_join_key(joining_table_key, joining_table, self.context["team_id"]) self._validate_key_uniqueness(field_name=field_name, table_name=source_table, team_id=self.context["team_id"]) + self._validate_group_type_index( + group_type_index=validated_data.get("group_type_index"), source_table=source_table + ) view_link = DataWarehouseJoin.objects.create(**validated_data) @@ -75,6 +79,12 @@ def _validate_join_key(self, join_key: Optional[str], table: Optional[str], team return + def _validate_group_type_index(self, group_type_index: Optional[int], source_table: Optional[str]) -> None: + if group_type_index is None: + return + if source_table != "groups": + raise serializers.ValidationError(f"Can only specify a group_type_index when joining onto groups table") + class ViewLinkViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): """ diff --git a/posthog/warehouse/models/join.py b/posthog/warehouse/models/join.py index 000b1ba34f9b2..8648c27a4463c 100644 --- a/posthog/warehouse/models/join.py +++ b/posthog/warehouse/models/join.py @@ -40,6 +40,11 @@ class DataWarehouseJoin(CreatedMetaFields, UUIDModel, DeletedMetaFields): joining_table_name = models.CharField(max_length=400) joining_table_key = models.CharField(max_length=400) field_name = models.CharField(max_length=400) + group_type_index = models.IntegerField( + null=True, + blank=True, + help_text="Group type index means the join only happens for that group type, not all groups", + ) def join_function( self, override_source_table_key: Optional[str] = None, override_joining_table_key: Optional[str] = None @@ -67,6 +72,23 @@ def _join_function( raise ResolutionError("Data Warehouse Join HogQL expression should be a Field node") right.chain = [join_to_add.to_table, *right.chain] + constraint: ast.And | ast.CompareOperation = ast.CompareOperation( + op=ast.CompareOperationOp.Eq, + left=left, + right=right, + ) + if self.group_type_index is not None and join_to_add.from_table == "groups": + constraint = ast.And( + exprs=[ + constraint, + ast.CompareOperation( + op=ast.CompareOperationOp.Eq, + left=ast.Field(chain=["groups", "index"]), + right=ast.Constant(value=self.group_type_index), + ), + ] + ) + join_expr = ast.JoinExpr( table=ast.SelectQuery( select=[ @@ -78,11 +100,7 @@ def _join_function( join_type="LEFT JOIN", alias=join_to_add.to_table, constraint=ast.JoinConstraint( - expr=ast.CompareOperation( - op=ast.CompareOperationOp.Eq, - left=left, - right=right, - ), + expr=constraint, constraint_type="ON", ), )