From 6e64697b080c39b3a9282842b015d573bf217ea6 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Fri, 22 Nov 2024 13:18:21 +0000 Subject: [PATCH] Almost there, just need a bit of styling --- .../components/PropertyValue.tsx | 4 +- .../VerticalNestedDND.stories.tsx | 2 +- .../VerticalNestedDND/VerticalNestedDND.tsx | 142 ++- frontend/src/queries/schema.json | 35 +- frontend/src/queries/schema.ts | 25 +- .../settings/environment/ChannelType.tsx | 864 +++++------------- package.json | 2 +- pnpm-lock.yaml | 8 +- posthog/hogql/database/schema/sessions_v1.py | 9 +- posthog/hogql/database/schema/sessions_v2.py | 7 +- .../database/schema/test/test_sessions_v1.py | 25 + .../database/schema/test/test_sessions_v2.py | 6 +- posthog/schema.py | 25 +- 13 files changed, 486 insertions(+), 668 deletions(-) diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx index 244f3713fcc..67dc2fed89a 100644 --- a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx @@ -26,6 +26,7 @@ export interface PropertyValueProps { autoFocus?: boolean eventNames?: string[] addRelativeDateTimeOptions?: boolean + forceSingleSelect?: boolean } export function PropertyValue({ @@ -39,11 +40,12 @@ export function PropertyValue({ autoFocus = false, eventNames = [], addRelativeDateTimeOptions = false, + forceSingleSelect = false, }: PropertyValueProps): JSX.Element { const { formatPropertyValueForDisplay, describeProperty, options } = useValues(propertyDefinitionsModel) const { loadPropertyValues } = useActions(propertyDefinitionsModel) - const isMultiSelect = operator && isOperatorMulti(operator) + const isMultiSelect = operator && isOperatorMulti(operator) && !forceSingleSelect const isDateTimeProperty = operator && isOperatorDate(operator) const propertyDefinitionType = propertyFilterTypeToPropertyDefinitionType(type) diff --git a/frontend/src/lib/lemon-ui/VerticalNestedDND/VerticalNestedDND.stories.tsx b/frontend/src/lib/lemon-ui/VerticalNestedDND/VerticalNestedDND.stories.tsx index fa285a71a3d..01b4281e8a5 100644 --- a/frontend/src/lib/lemon-ui/VerticalNestedDND/VerticalNestedDND.stories.tsx +++ b/frontend/src/lib/lemon-ui/VerticalNestedDND/VerticalNestedDND.stories.tsx @@ -20,7 +20,7 @@ interface ExampleSubItem { } interface ExampleItem { id: UniqueIdentifier - items: ExampleSubItem[] + items?: ExampleSubItem[] } let counter = 0 diff --git a/frontend/src/lib/lemon-ui/VerticalNestedDND/VerticalNestedDND.tsx b/frontend/src/lib/lemon-ui/VerticalNestedDND/VerticalNestedDND.tsx index 735032793b6..b6db0dd6954 100644 --- a/frontend/src/lib/lemon-ui/VerticalNestedDND/VerticalNestedDND.tsx +++ b/frontend/src/lib/lemon-ui/VerticalNestedDND/VerticalNestedDND.tsx @@ -28,25 +28,28 @@ import { } from '@dnd-kit/sortable' import type { Transform } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities' -import { IconBuilding, IconTrash } from '@posthog/icons' +import { IconTrash } from '@posthog/icons' +import { IconDragHandle } from 'lib/lemon-ui/icons' import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton' import debounce from 'lodash.debounce' import isEqual from 'lodash.isequal' import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal, unstable_batchedUpdates } from 'react-dom' + +const NOOP = (): void => {} export interface VDNDChildItem { id: UniqueIdentifier } export interface VNDNDContainerItem { - items: T[] + items?: T[] id: UniqueIdentifier } export interface VerticalNestedDNDProps> { initialItems: Item[] - renderContainerItem: (item: Item) => JSX.Element | null - renderChildItem: (item: ChildItem) => JSX.Element | null + renderContainerItem: (item: Item, callbacks: { updateContainerItem: (item: Item) => void }) => JSX.Element | null + renderChildItem: (item: ChildItem, callbacks: { updateChildItem: (item: ChildItem) => void }) => JSX.Element | null createNewContainerItem(): Item createNewChildItem(): ChildItem onChange?(items: Item[]): void @@ -114,7 +117,7 @@ export function VerticalNestedDND 0) { + if (containerItems && containerItems.length > 0) { // Return the closest droppable within that container overId = closestCenter({ ...args, @@ -150,12 +153,12 @@ export function VerticalNestedDND items[key].items.some((item) => item.id === id)) + return Object.keys(items).find((key) => items[key].items?.some((item) => item.id === id)) } const findChildItem = (id: UniqueIdentifier): ChildItem | undefined => { for (const containerId in items) { - const item = items[containerId].items.find((item) => item.id === id) + const item = items[containerId].items?.find((item) => item.id === id) if (item) { return item } @@ -168,8 +171,12 @@ export function VerticalNestedDND ChildItem.id === id) + return childItems.findIndex((ChildItem) => ChildItem.id === id) } const onDragCancel = (): void => { @@ -235,8 +242,8 @@ export function VerticalNestedDND { - const activeItems = items[activeContainerId].items - const overItems = items[overContainerId].items + const activeItems = items[activeContainerId].items || [] + const overItems = items[overContainerId].items || [] const overIndex = overItems.findIndex((ChildItem) => ChildItem.id === overId) const activeIndex = activeItems.findIndex((ChildItem) => ChildItem.id === active.id) @@ -264,7 +271,7 @@ export function VerticalNestedDND { - const overItems = items[overContainerId].items + const overItems = items[overContainerId].items || [] const overIndex = overItems.findIndex((ChildItem) => ChildItem.id === overId) const activeIndex = overItems.findIndex((ChildItem) => ChildItem.id === active.id) @@ -328,16 +335,17 @@ export function VerticalNestedDND ChildItem.id === active.id - ) - const overIndex = items[overContainerId].items.findIndex((ChildItem) => ChildItem.id === overId) + const overItems = items[overContainerId].items || [] + const activeItems = items[activeContainerId].items || [] + const activeIndex = activeItems.findIndex((ChildItem) => ChildItem.id === active.id) + const overIndex = overItems.findIndex((ChildItem) => ChildItem.id === overId) if (activeIndex !== overIndex) { setItems((items) => { + const overItems = items[overContainerId].items || [] const newOverContainer = { ...items[overContainerId], - items: arrayMove(items[overContainerId].items, activeIndex, overIndex), + items: arrayMove(overItems, activeIndex, overIndex), } return { ...items, @@ -356,15 +364,19 @@ export function VerticalNestedDND ( handleRemove(containerId)} renderContainerItem={renderContainerItem} containerItemId={containerId} item={items[containerId]} onAddChild={handleAddChild} + updateContainerItem={updateContainerItem} > - - {items[containerId].items.map((value, index) => { + + {(items[containerId].items || []).map((value, index) => { return ( ) @@ -407,7 +420,15 @@ export function VerticalNestedDND + return ( + + ) } function renderContainerDragOverlay(containerId: UniqueIdentifier): JSX.Element | null { @@ -421,10 +442,17 @@ export function VerticalNestedDND {}} + onAddChild={NOOP} + updateContainerItem={NOOP} > - {items[containerId].items.map((item) => ( - + {(items[containerId].items || []).map((item) => ( + ))} ) @@ -455,7 +483,37 @@ export function VerticalNestedDND ({ + ...items, + [item.id]: item, + })) + } + + function updateChildItem(item: ChildItem): void { + const containerId = findContainer(item.id) + + if (!containerId) { + return + } + setItems((items) => { + const container = items[containerId] + return { + ...items, + [containerId]: { + ...container, + items: (container.items || []).map((childItem) => { + if (childItem.id === item.id) { + return item + } + return childItem + }), }, } }) @@ -480,7 +538,8 @@ interface SortableItemProps { handle: boolean disabled?: boolean getIndex(id: UniqueIdentifier): number - renderChildItem(item: Item): JSX.Element | null + renderChildItem(item: Item, callbacks: { updateChildItem: (item: Item) => void }): JSX.Element | null + updateChildItem(item: Item): void item: Item } @@ -490,6 +549,7 @@ function SortableItem({ index, handle, renderChildItem, + updateChildItem, item, }: SortableItemProps): JSX.Element { const { setNodeRef, setActivatorNodeRef, listeners, isDragging, isSorting, transform, transition } = useSortable({ @@ -511,6 +571,7 @@ function SortableItem({ fadeIn={mountedWhileDragging} listeners={listeners} renderChildItem={renderChildItem} + updateChildItem={updateChildItem} item={item} /> ) @@ -592,7 +653,8 @@ export interface ContainerProps> { isDragging?: boolean transition?: string transform?: string - renderContainerItem(item: Item): JSX.Element | null + renderContainerItem(item: Item, callbacks: { updateContainerItem: (item: Item) => void }): JSX.Element | null + updateContainerItem(item: Item): void item: Item } @@ -615,6 +677,7 @@ export const Container = forwardRef(function Container_, @@ -639,8 +702,13 @@ export const Container = forwardRef(function Container_
-
{renderContainerItem ? renderContainerItem(item) : Container {containerItemId}}
-
+
+ {renderContainerItem ? ( + renderContainerItem(item, { updateContainerItem }) + ) : ( + Container {containerItemId} + )} +
{placeholder ? children :
    {children}
} @@ -675,7 +743,8 @@ export interface ChildItemProps { childItemId: UniqueIdentifier item: Item onRemove?(): void - renderChildItem(item: Item): JSX.Element | null + renderChildItem(item: Item, callbacks: { updateChildItem: (item: Item) => void }): JSX.Element | null + updateChildItem(item: Item): void } export const ChildItem = React.memo( @@ -698,6 +767,8 @@ export const ChildItem = React.memo( childItemId, wrapperStyle, renderChildItem, + updateChildItem, + item, ...props }, ref @@ -730,8 +801,9 @@ export const ChildItem = React.memo( className="flex flex-row justify-between w-full space-x-2" > -
{renderChildItem ? renderChildItem(childItemId) : Item {childItemId}}
-
+
+ {renderChildItem ? renderChildItem(item, { updateChildItem }) : Item {childItemId}} +
@@ -749,8 +821,10 @@ export function Remove(props: LemonButtonProps): JSX.Element { export const Handle = forwardRef(function Handle_(props, ref) { return ( - - + +
+ +
) }) diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 1da587c9fcd..7713e75c6f7 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -3919,6 +3919,9 @@ "CustomChannelCondition": { "additionalProperties": false, "properties": { + "id": { + "type": "string" + }, "key": { "$ref": "#/definitions/CustomChannelField" }, @@ -3939,7 +3942,7 @@ ] } }, - "required": ["key", "op"], + "required": ["key", "op", "id"], "type": "object" }, "CustomChannelField": { @@ -3959,14 +3962,17 @@ "combiner": { "$ref": "#/definitions/FilterLogicalOperator" }, - "conditions": { + "id": { + "type": "string" + }, + "items": { "items": { "$ref": "#/definitions/CustomChannelCondition" }, "type": "array" } }, - "required": ["conditions", "combiner", "channel_type"], + "required": ["items", "combiner", "channel_type", "id"], "type": "object" }, "CustomEventConversionGoal": { @@ -5343,6 +5349,29 @@ "Day": { "type": "integer" }, + "DefaultChannelTypes": { + "enum": [ + "Cross Network", + "Paid Search", + "Paid Social", + "Paid Video", + "Paid Shopping", + "Paid Unknown", + "Direct", + "Organic Search", + "Organic Social", + "Organic Video", + "Organic Shopping", + "Push", + "SMS", + "Audio", + "Email", + "Referral", + "Affiliate", + "Unknown" + ], + "type": "string" + }, "DurationType": { "enum": ["duration", "active_seconds", "inactive_seconds"], "type": "string" diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index a6a78a93cce..fae45851247 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -2558,10 +2558,33 @@ export interface CustomChannelCondition { key: CustomChannelField value?: string | string[] op: CustomChannelOperator + id: string } export interface CustomChannelRule { - conditions: CustomChannelCondition[] + items: CustomChannelCondition[] combiner: FilterLogicalOperator channel_type: string + id: string +} + +export enum DefaultChannelTypes { + CrossNetwork = 'Cross Network', + PaidSearch = 'Paid Search', + PaidSocial = 'Paid Social', + PaidVideo = 'Paid Video', + PaidShopping = 'Paid Shopping', + PaidUnknown = 'Paid Unknown', + Direct = 'Direct', + OrganicSearch = 'Organic Search', + OrganicSocial = 'Organic Social', + OrganicVideo = 'Organic Video', + OrganicShopping = 'Organic Shopping', + Push = 'Push', + SMS = 'SMS', + Audio = 'Audio', + Email = 'Email', + Referral = 'Referral', + Affiliate = 'Affiliate', + Unknown = 'Unknown', } diff --git a/frontend/src/scenes/settings/environment/ChannelType.tsx b/frontend/src/scenes/settings/environment/ChannelType.tsx index 668fc226df4..c3f4088f49b 100644 --- a/frontend/src/scenes/settings/environment/ChannelType.tsx +++ b/frontend/src/scenes/settings/environment/ChannelType.tsx @@ -1,614 +1,252 @@ -// /* eslint-disable @typescript-eslint/explicit-function-return-type */ -// import { useActions, useValues } from 'kea' -// import { LemonButton } from 'lib/lemon-ui/LemonButton' -// import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -// import React, { useEffect, useRef, useState } from 'react' -// import { teamLogic } from 'scenes/teamLogic' -// -// import { -// Active, -// Announcements, -// closestCenter, -// CollisionDetection, -// defaultDropAnimationSideEffects, -// DndContext, -// DragOverlay, -// DropAnimation, -// KeyboardCoordinateGetter, -// KeyboardSensor, -// MeasuringConfiguration, -// Modifiers, -// MouseSensor, -// PointerActivationConstraint, -// ScreenReaderInstructions, -// TouchSensor, -// UniqueIdentifier, -// useSensor, -// useSensors, -// } from '@dnd-kit/core' -// import { -// AnimateLayoutChanges, -// arrayMove, -// NewIndexGetter, -// rectSortingStrategy, -// SortableContext, -// sortableKeyboardCoordinates, -// SortingStrategy, -// useSortable, -// verticalListSortingStrategy, -// } from '@dnd-kit/sortable' -// -// import { CustomChannelRule } from '~/queries/schema' -// import { LemonInput } from 'lib/lemon-ui/LemonInput' -// import { createPortal } from 'react-dom' -// -// import { Item, List, Wrapper } from '../../components' -// import React, { forwardRef } from 'react' -// -// export interface Props { -// children: React.ReactNode -// columns?: number -// style?: React.CSSProperties -// horizontal?: boolean -// } -// -// export const List = forwardRef(({ children, columns = 1, horizontal, style }: Props, ref) => { -// return
    {children}
-// }) -// -// export function createRange(length: number, initializer: (index: number) => any = defaultInitializer): T[] { -// return [...new Array(length)].map((_, index) => initializer(index)) -// } -// -export function ChannelType(): JSX.Element { - return
- // const { updateCurrentTeam } = useActions(teamLogic) - // const { currentTeam } = useValues(teamLogic) - // const { reportCustomChannelTypeRulesUpdated } = useActions(eventUsageLogic) - // - // const savedCustomChannelTypeRules = - // currentTeam?.modifiers?.customChannelTypeRules ?? currentTeam?.default_modifiers?.customChannelTypeRules ?? null - // const [customChannelTypeRules, setCustomChannelTypeRules] = useState( - // savedCustomChannelTypeRules ? JSON.stringify(savedCustomChannelTypeRules) : '' - // ) - // - // const handleChange = (rules: string): void => { - // let parsed: CustomChannelRule[] = [] - // try { - // parsed = JSON.parse(rules) - // } catch (e) { - // return - // } - // - // updateCurrentTeam({ modifiers: { ...currentTeam?.modifiers, customChannelTypeRules: parsed } }) - // reportCustomChannelTypeRulesUpdated(parsed.length) - // } - // - // return ( - // <> - //

Set your custom channel type

- // - //
- // handleChange(customChannelTypeRules)}> - // Save - // - //
- // - // ) +import { VerticalNestedDND } from 'lib/lemon-ui/VerticalNestedDND/VerticalNestedDND' +import { CustomChannelCondition, CustomChannelField, CustomChannelOperator, CustomChannelRule } from '~/queries/schema' +import { useActions, useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import { useMemo, useRef, useState } from 'react' +import { genericOperatorMap, UnexpectedNeverError, uuid } from 'lib/utils' +import { FilterLogicalOperator, PropertyFilterType, PropertyOperator } from '~/types' +import isEqual from 'lodash.isequal' +import debounce from 'lodash.debounce' +import { LemonSelect } from 'lib/lemon-ui/LemonSelect' +import { PropertyValue } from 'lib/components/PropertyFilters/components/PropertyValue' +import { LemonInputSelect } from 'lib/lemon-ui/LemonInputSelect' + +const combinerOptions = [ + { label: 'All', value: FilterLogicalOperator.And }, + { label: 'Any', value: FilterLogicalOperator.Or }, +] + +const keyOptions = [ + { + label: 'Referring domain', + value: CustomChannelField.ReferringDomain, + }, + { + label: 'UTM Source', + value: CustomChannelField.UTMSource, + }, + { + label: 'UTM Medium', + value: CustomChannelField.UTMMedium, + }, + { + label: 'UTM Campaign', + value: CustomChannelField.UTMCampaign, + }, +] + +const opOptions = Object.values(CustomChannelOperator).map((op) => { + return { + label: genericOperatorMap[op], + value: op, + } +}) + +const isNullary = (operator: CustomChannelOperator): boolean => { + return [CustomChannelOperator.IsSet, CustomChannelOperator.IsNotSet].includes(operator) +} + +function opToPropertyOperator(op: CustomChannelOperator): PropertyOperator { + switch (op) { + case CustomChannelOperator.Exact: + return PropertyOperator.Exact + case CustomChannelOperator.IsNot: + return PropertyOperator.IsNot + case CustomChannelOperator.IsSet: + return PropertyOperator.IsSet + case CustomChannelOperator.IsNotSet: + return PropertyOperator.IsNotSet + case CustomChannelOperator.IContains: + return PropertyOperator.IContains + case CustomChannelOperator.NotIContains: + return PropertyOperator.NotIContains + case CustomChannelOperator.Regex: + return PropertyOperator.Regex + case CustomChannelOperator.NotRegex: + return PropertyOperator.NotRegex + default: + throw new UnexpectedNeverError(op) + } +} + +function keyToSessionproperty(key: CustomChannelField): string { + switch (key) { + case CustomChannelField.ReferringDomain: + return '$entry_referring_domain' + case CustomChannelField.UTMSource: + return '$entry_utm_source' + case CustomChannelField.UTMMedium: + return '$entry_utm_medium' + case CustomChannelField.UTMCampaign: + return '$entry_utm_campaign' + default: + throw new UnexpectedNeverError(key) + } +} + +export function ChannelType(): JSX.Element { + const { updateCurrentTeam } = useActions(teamLogic) + const { currentTeam } = useValues(teamLogic) + const { reportCustomChannelTypeRulesUpdated } = useActions(eventUsageLogic) + + const savedCustomChannelTypeRules = + currentTeam?.modifiers?.customChannelTypeRules ?? currentTeam?.default_modifiers?.customChannelTypeRules ?? null + const [customChannelTypeRules] = useState(() => + (savedCustomChannelTypeRules || []).map((rule) => { + return { + ...rule, + id: rule.id || uuid(), + items: (rule.items || []).map((item) => { + return { + ...item, + id: item.id || uuid(), + } + }), + } + }) + ) + + const lastSavedRef = useRef(customChannelTypeRules) + + const debouncedHandleChange = useMemo( + () => + debounce( + function handleChange(rules: CustomChannelRule[]): void { + // strip conditions where the value is empty, and strip empty rules + rules = rules + .map((rule) => { + return { + ...rule, + conditions: rule.items.filter((condition) => condition.value !== ''), + } + }) + .filter((rule) => { + return rule.conditions.length > 0 && rule.channel_type !== '' + }) + + // don't update if the rules are the same as the last saved rules + if (isEqual(rules, lastSavedRef.current)) { + return + } + + updateCurrentTeam({ modifiers: { ...currentTeam?.modifiers, customChannelTypeRules: rules } }) + reportCustomChannelTypeRulesUpdated(rules.length) + }, + 500, + { trailing: true, maxWait: 2000 } + ), + [updateCurrentTeam, reportCustomChannelTypeRulesUpdated, currentTeam?.modifiers] + ) + + return ( + + ) +} + +export interface ChannelTypeEditorProps { + handleChange: (rules: CustomChannelRule[]) => void + initialCustomChannelTypeRules: CustomChannelRule[] +} + +export function ChannelTypeEditor({ + handleChange, + initialCustomChannelTypeRules, +}: ChannelTypeEditorProps): JSX.Element { + return ( + + initialItems={initialCustomChannelTypeRules} + renderContainerItem={(rule, { updateContainerItem }) => { + return ( +
+
+ Set Channel type to{' '} + + updateContainerItem({ + ...rule, + channel_type: channelType[0], + }) + } + /> +
+
+ {rule.items.length <= 1 ? ( + 'When' + ) : ( +
+ When{' '} + updateContainerItem({ ...rule, combiner })} + /> +
+ )} +
+
+ ) + }} + renderChildItem={(rule, { updateChildItem }) => { + return ( +
+
+ + value={rule.key} + options={keyOptions} + onChange={(key) => updateChildItem({ ...rule, key })} + /> + + value={rule.op} + options={opOptions} + onChange={(op) => updateChildItem({ ...rule, op })} + /> +
+ {isNullary(rule.op) ? null : ( + { + updateChildItem({ ...rule, value: propertyValue }) + }} + operator={opToPropertyOperator(rule.op)} + value={rule.value} + /> + )} +
+ ) + }} + createNewContainerItem={() => { + return { + id: uuid(), + items: [ + { + id: uuid(), + key: CustomChannelField.ReferringDomain, + op: CustomChannelOperator.Exact, + value: [], + }, + ], + channel_type: '', + combiner: FilterLogicalOperator.And, + } + }} + createNewChildItem={() => { + return { + id: uuid(), + key: CustomChannelField.ReferringDomain, + op: CustomChannelOperator.Exact, + value: '', + } + }} + onChange={handleChange} + /> + ) } -// -// export interface ChannelTypeCustomRulesProps { -// customRules?: CustomChannelRule[] | null -// setCustomRules: (customRules: CustomChannelRule[]) => void -// } -// -// export function ChannelTypeCustomRules({ -// customRules: _customRules, -// setCustomRules: _setCustomRules, -// }: ChannelTypeCustomRulesProps): JSX.Element { -// const customRules = _customRules != null ? _customRules : [] -// const [localCustomRules, setLocalCustomRules] = useState(customRules) -// -// const updateCustomRules = (customRules: CustomChannelRule[]): void => { -// setLocalCustomRules(customRules) -// _setCustomRules(customRules) -// } -// -// const onAddFilter = (filter: CustomChannelRule): void => { -// updateCustomRules([...customRules, filter]) -// } -// const onEditFilter = (index: number, filter: CustomChannelRule): void => { -// const newCustomRules = customRules.map((f, i) => { -// if (i === index) { -// return filter -// } -// return f -// }) -// updateCustomRules(newCustomRules) -// } -// const onRemoveFilter = (index: number): void => { -// updateCustomRules(customRules.filter((_, i) => i !== index)) -// } -// -// function onSortEnd({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }): void { -// function move(arr: CustomChannelRule[], from: number, to: number): CustomChannelRule[] { -// const clone = [...arr] -// Array.prototype.splice.call(clone, to, 0, Array.prototype.splice.call(clone, from, 1)[0]) -// return clone.map((child, order) => ({ ...child, order })) -// } -// updateCustomRules(move(customRules, oldIndex, newIndex)) -// } -// -// return ( -//
-//
-// -//
-//
-// -//
-//
-// ) -// } -// -// export interface SortableProps { -// activationConstraint?: PointerActivationConstraint -// animateLayoutChanges?: AnimateLayoutChanges -// adjustScale?: boolean -// collisionDetection?: CollisionDetection -// coordinateGetter?: KeyboardCoordinateGetter -// Container?: any // To-do: Fix me -// dropAnimation?: DropAnimation | null -// getNewIndex?: NewIndexGetter -// handle?: boolean -// itemCount?: number -// items?: UniqueIdentifier[] -// measuring?: MeasuringConfiguration -// modifiers?: Modifiers -// renderItem?: any -// removable?: boolean -// reorderItems?: typeof arrayMove -// strategy?: SortingStrategy -// style?: React.CSSProperties -// useDragOverlay?: boolean -// getItemStyles?(args: { -// id: UniqueIdentifier -// index: number -// isSorting: boolean -// isDragOverlay: boolean -// overIndex: number -// isDragging: boolean -// }): React.CSSProperties -// wrapperStyle?(args: { -// active: Pick | null -// index: number -// isDragging: boolean -// id: UniqueIdentifier -// }): React.CSSProperties -// isDisabled?(id: UniqueIdentifier): boolean -// } -// -// const dropAnimationConfig: DropAnimation = { -// sideEffects: defaultDropAnimationSideEffects({ -// styles: { -// active: { -// opacity: '0.5', -// }, -// }, -// }), -// } -// -// const screenReaderInstructions: ScreenReaderInstructions = { -// draggable: ` -// To pick up a sortable item, press the space bar. -// While sorting, use the arrow keys to move the item. -// Press space again to drop the item in its new position, or press escape to cancel. -// `, -// } -// -// export function Sortable({ -// activationConstraint, -// animateLayoutChanges, -// adjustScale = false, -// Container = List, -// collisionDetection = closestCenter, -// coordinateGetter = sortableKeyboardCoordinates, -// dropAnimation = dropAnimationConfig, -// getItemStyles = () => ({}), -// getNewIndex, -// handle = false, -// itemCount = 16, -// items: initialItems, -// isDisabled = () => false, -// measuring, -// modifiers, -// removable, -// renderItem, -// reorderItems = arrayMove, -// strategy = rectSortingStrategy, -// style, -// useDragOverlay = true, -// wrapperStyle = () => ({}), -// }: SortableProps): JSX.Element { -// const [items, setItems] = useState( -// // @ts-expect-error -// () => initialItems ?? createRange(itemCount, (index) => index + 1) -// ) -// const [activeId, setActiveId] = useState(null) -// const sensors = useSensors( -// useSensor(MouseSensor, { -// activationConstraint, -// }), -// useSensor(TouchSensor, { -// activationConstraint, -// }), -// useSensor(KeyboardSensor, { -// // Disable smooth scrolling in Cypress automated tests -// scrollBehavior: 'Cypress' in window ? 'auto' : undefined, -// coordinateGetter, -// }) -// ) -// const isFirstAnnouncement = useRef(true) -// const getIndex = (id: UniqueIdentifier) => items.indexOf(id) -// const getPosition = (id: UniqueIdentifier) => getIndex(id) + 1 -// const activeIndex = activeId ? getIndex(activeId) : -1 -// const handleRemove = removable -// ? (id: UniqueIdentifier) => setItems((items) => items.filter((item) => item !== id)) -// : undefined -// const announcements: Announcements = { -// onDragStart({ active: { id } }) { -// return `Picked up sortable item ${String(id)}. Sortable item ${id} is in position ${getPosition(id)} of ${ -// items.length -// }` -// }, -// onDragOver({ active, over }) { -// // In this specific use-case, the picked up item's `id` is always the same as the first `over` id. -// // The first `onDragOver` event therefore doesn't need to be announced, because it is called -// // immediately after the `onDragStart` announcement and is redundant. -// if (isFirstAnnouncement.current === true) { -// isFirstAnnouncement.current = false -// return -// } -// -// if (over) { -// return `Sortable item ${active.id} was moved into position ${getPosition(over.id)} of ${items.length}` -// } -// -// return -// }, -// onDragEnd({ active, over }) { -// if (over) { -// return `Sortable item ${active.id} was dropped at position ${getPosition(over.id)} of ${items.length}` -// } -// -// return -// }, -// onDragCancel({ active: { id } }) { -// return `Sorting was cancelled. Sortable item ${id} was dropped and returned to position ${getPosition( -// id -// )} of ${items.length}.` -// }, -// } -// -// useEffect(() => { -// if (!activeId) { -// isFirstAnnouncement.current = true -// } -// }, [activeId]) -// -// return ( -// { -// if (!active) { -// return -// } -// -// setActiveId(active.id) -// }} -// onDragEnd={({ over }) => { -// setActiveId(null) -// -// if (over) { -// const overIndex = getIndex(over.id) -// if (activeIndex !== overIndex) { -// setItems((items) => reorderItems(items, activeIndex, overIndex)) -// } -// } -// }} -// onDragCancel={() => setActiveId(null)} -// measuring={measuring} -// modifiers={modifiers} -// > -// -// -// -// {items.map((value, index) => ( -// -// ))} -// -// -// -// {useDragOverlay -// ? createPortal( -// -// {activeId ? ( -// -// ) : null} -// , -// document.body -// ) -// : null} -// -// ) -// } -// -// interface SortableItemProps { -// animateLayoutChanges?: AnimateLayoutChanges -// disabled?: boolean -// getNewIndex?: NewIndexGetter -// id: UniqueIdentifier -// index: number -// handle: boolean -// useDragOverlay?: boolean -// onRemove?(id: UniqueIdentifier): void -// style(values: any): React.CSSProperties -// renderItem?(args: any): React.ReactElement -// wrapperStyle: SortableProps['wrapperStyle'] -// } -// -// export function SortableItem({ -// disabled, -// animateLayoutChanges, -// getNewIndex, -// handle, -// id, -// index, -// onRemove, -// style, -// renderItem, -// useDragOverlay, -// wrapperStyle, -// }: SortableItemProps): JSX.Element { -// const { -// active, -// attributes, -// isDragging, -// isSorting, -// listeners, -// overIndex, -// setNodeRef, -// setActivatorNodeRef, -// transform, -// transition, -// } = useSortable({ -// id, -// animateLayoutChanges, -// disabled, -// getNewIndex, -// }) -// -// return ( -// onRemove(id) : undefined} -// transform={transform} -// transition={transition} -// wrapperStyle={wrapperStyle?.({ index, isDragging, active, id })} -// listeners={listeners} -// data-index={index} -// data-id={id} -// dragOverlay={!useDragOverlay && isDragging} -// {...attributes} -// /> -// ) -// } -// -// -// export interface Props { -// dragOverlay?: boolean; -// color?: string; -// disabled?: boolean; -// dragging?: boolean; -// handle?: boolean; -// handleProps?: any; -// height?: number; -// index?: number; -// fadeIn?: boolean; -// transform?: Transform | null; -// listeners?: DraggableSyntheticListeners; -// sorting?: boolean; -// style?: React.CSSProperties; -// transition?: string | null; -// wrapperStyle?: React.CSSProperties; -// value: React.ReactNode; -// onRemove?(): void; -// renderItem?(args: { -// dragOverlay: boolean; -// dragging: boolean; -// sorting: boolean; -// index: number | undefined; -// fadeIn: boolean; -// listeners: DraggableSyntheticListeners; -// ref: React.Ref; -// style: React.CSSProperties | undefined; -// transform: Props['transform']; -// transition: Props['transition']; -// value: Props['value']; -// }): React.ReactElement; -// } -// -// export const Item = React.memo( -// React.forwardRef( -// ( -// { -// color, -// dragOverlay, -// dragging, -// disabled, -// fadeIn, -// handle, -// handleProps, -// height, -// index, -// listeners, -// onRemove, -// renderItem, -// sorting, -// style, -// transition, -// transform, -// value, -// wrapperStyle, -// ...props -// }, -// ref -// ) => { -// useEffect(() => { -// if (!dragOverlay) { -// return; -// } -// -// document.body.style.cursor = 'grabbing'; -// -// return () => { -// document.body.style.cursor = ''; -// }; -// }, [dragOverlay]); -// -// return renderItem ? ( -// renderItem({ -// dragOverlay: Boolean(dragOverlay), -// dragging: Boolean(dragging), -// sorting: Boolean(sorting), -// index, -// fadeIn: Boolean(fadeIn), -// listeners, -// ref, -// style, -// transform, -// transition, -// value, -// }) -// ) : ( -//
  • -//
    -// {value} -// -// {onRemove ? ( -// -// ) : null} -// {handle ? : null} -// -//
    -//
  • -// ); -// } -// ) -// ); diff --git a/package.json b/package.json index a03d4986f7e..cfaff520f9a 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@microlink/react-json-view": "^1.21.3", "@monaco-editor/react": "4.6.0", "@posthog/hogvm": "^1.0.60", - "@posthog/icons": "0.9.1", + "@posthog/icons": "0.9.2", "@posthog/plugin-scaffold": "^1.4.4", "@react-hook/size": "^2.1.2", "@rrweb/types": "2.0.0-alpha.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b272feb3b6b..d75d43c5ea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,8 +53,8 @@ dependencies: specifier: ^1.0.60 version: 1.0.60(luxon@3.5.0) '@posthog/icons': - specifier: 0.9.1 - version: 0.9.1(react-dom@18.2.0)(react@18.2.0) + specifier: 0.9.2 + version: 0.9.2(react-dom@18.2.0)(react@18.2.0) '@posthog/plugin-scaffold': specifier: ^1.4.4 version: 1.4.4 @@ -5441,8 +5441,8 @@ packages: luxon: 3.5.0 dev: false - /@posthog/icons@0.9.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-9zlU1H7MZm2gSh1JsDzM25km6VDc/Y7HdNf6RyP5sUiHCHVMKhQQ8TA2IMq55v/uTFRc5Yen6BagOUvunD2kqQ==} + /@posthog/icons@0.9.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-SL79DvkAnVw+urYVrSPQY/CvY71NoSmYFhTH+gZFdwTYVe4zL7x6uADjPfmtr/wdyG3xj6M6CGIJlP00lW3Srg==} peerDependencies: react: '>=16.14.0' react-dom: '>=16.14.0' diff --git a/posthog/hogql/database/schema/sessions_v1.py b/posthog/hogql/database/schema/sessions_v1.py index 5b5667ccc79..178aaec31ab 100644 --- a/posthog/hogql/database/schema/sessions_v1.py +++ b/posthog/hogql/database/schema/sessions_v1.py @@ -19,7 +19,6 @@ from posthog.hogql.database.models import ( ) from posthog.hogql.database.schema.channel_type import ( create_channel_type_expr, - POSSIBLE_CHANNEL_TYPES, ChannelTypeExprs, ) from posthog.hogql.database.schema.util.where_clause_extractor import SessionMinTimestampWhereClauseExtractorV1 @@ -30,7 +29,7 @@ from posthog.models.sessions.sql import ( SELECT_SESSION_PROP_STRING_VALUES_SQL, ) from posthog.queries.insight import insight_sync_execute -from posthog.schema import BounceRatePageViewMode +from posthog.schema import BounceRatePageViewMode, DefaultChannelTypes if TYPE_CHECKING: from posthog.models.team import Team @@ -410,7 +409,11 @@ def get_lazy_session_table_values_v1(key: str, search_term: Optional[str], team: # the sessions table does not have a properties json object like the events and person tables if key == "$channel_type": - return [[name] for name in POSSIBLE_CHANNEL_TYPES if not search_term or search_term.lower() in name.lower()] + return [ + [entry.value] + for entry in DefaultChannelTypes + if not search_term or search_term.lower() in entry.value.lower() + ] field_definition = LAZY_SESSIONS_FIELDS.get(key) if not field_definition: diff --git a/posthog/hogql/database/schema/sessions_v2.py b/posthog/hogql/database/schema/sessions_v2.py index 9cafae2c86c..e36e46bc333 100644 --- a/posthog/hogql/database/schema/sessions_v2.py +++ b/posthog/hogql/database/schema/sessions_v2.py @@ -19,7 +19,6 @@ from posthog.hogql.database.models import ( ) from posthog.hogql.database.schema.channel_type import ( create_channel_type_expr, - POSSIBLE_CHANNEL_TYPES, ChannelTypeExprs, ) from posthog.hogql.database.schema.sessions_v1 import null_if_empty @@ -32,7 +31,7 @@ from posthog.models.raw_sessions.sql import ( RAW_SELECT_SESSION_PROP_STRING_VALUES_SQL_WITH_FILTER, ) from posthog.queries.insight import insight_sync_execute -from posthog.schema import BounceRatePageViewMode, CustomChannelRule +from posthog.schema import BounceRatePageViewMode, CustomChannelRule, DefaultChannelTypes if TYPE_CHECKING: from posthog.models.team import Team @@ -503,7 +502,9 @@ def get_lazy_session_table_values_v2(key: str, search_term: Optional[str], team: else: custom_channel_types = [] default_channel_types = [ - name for name in POSSIBLE_CHANNEL_TYPES if not search_term or search_term.lower() in name.lower() + entry.value + for entry in DefaultChannelTypes + if not search_term or search_term.lower() in entry.value.lower() ] # merge the list, keep the order, and remove duplicates return [[name] for name in list(dict.fromkeys(custom_channel_types + default_channel_types))] diff --git a/posthog/hogql/database/schema/test/test_sessions_v1.py b/posthog/hogql/database/schema/test/test_sessions_v1.py index 77f41fd2f6b..7c1a2f45627 100644 --- a/posthog/hogql/database/schema/test/test_sessions_v1.py +++ b/posthog/hogql/database/schema/test/test_sessions_v1.py @@ -347,3 +347,28 @@ class TestGetLazySessionProperties(ClickhouseTestMixin, APIBaseTest): results = get_lazy_session_table_properties_v1(None) for prop in results: get_lazy_session_table_values_v1(key=prop["id"], team=TEAM, search_term=None) + + def test_custom_channel_types(self): + results = get_lazy_session_table_values_v1(key="$channel_type", team=self.team, search_term=None) + # the custom channel types should be first, there's should be no duplicates, and any custom rules for existing + # channel types should be bumped to the top + assert results == [ + ["Cross Network"], + ["Paid Search"], + ["Paid Social"], + ["Paid Video"], + ["Paid Shopping"], + ["Paid Unknown"], + ["Direct"], + ["Organic Search"], + ["Organic Social"], + ["Organic Video"], + ["Organic Shopping"], + ["Push"], + ["SMS"], + ["Audio"], + ["Email"], + ["Referral"], + ["Affiliate"], + ["Unknown"], + ] diff --git a/posthog/hogql/database/schema/test/test_sessions_v2.py b/posthog/hogql/database/schema/test/test_sessions_v2.py index 6c837c42a37..0130dce96dc 100644 --- a/posthog/hogql/database/schema/test/test_sessions_v2.py +++ b/posthog/hogql/database/schema/test/test_sessions_v2.py @@ -705,9 +705,9 @@ class TestGetLazySessionProperties(ClickhouseTestMixin, APIBaseTest): def test_custom_channel_types(self): self.team.modifiers = { "customChannelTypeRules": [ - {"conditions": [], "combiner": FilterLogicalOperator.AND_, "channel_type": "Test Channel Type"}, - {"conditions": [], "combiner": FilterLogicalOperator.AND_, "channel_type": "Paid Social"}, - {"conditions": [], "combiner": FilterLogicalOperator.AND_, "channel_type": "Test Channel Type"}, + {"items": [], "combiner": FilterLogicalOperator.AND_, "channel_type": "Test Channel Type", "id": "1"}, + {"items": [], "combiner": FilterLogicalOperator.AND_, "channel_type": "Paid Social", "id": "2"}, + {"items": [], "combiner": FilterLogicalOperator.AND_, "channel_type": "Test Channel Type", "id": "3"}, ] } self.team.save() diff --git a/posthog/schema.py b/posthog/schema.py index fee008aadf0..5248e113a8c 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -675,6 +675,27 @@ class Day(RootModel[int]): root: int +class DefaultChannelTypes(StrEnum): + CROSS_NETWORK = "Cross Network" + PAID_SEARCH = "Paid Search" + PAID_SOCIAL = "Paid Social" + PAID_VIDEO = "Paid Video" + PAID_SHOPPING = "Paid Shopping" + PAID_UNKNOWN = "Paid Unknown" + DIRECT = "Direct" + ORGANIC_SEARCH = "Organic Search" + ORGANIC_SOCIAL = "Organic Social" + ORGANIC_VIDEO = "Organic Video" + ORGANIC_SHOPPING = "Organic Shopping" + PUSH = "Push" + SMS = "SMS" + AUDIO = "Audio" + EMAIL = "Email" + REFERRAL = "Referral" + AFFILIATE = "Affiliate" + UNKNOWN = "Unknown" + + class DurationType(StrEnum): DURATION = "duration" ACTIVE_SECONDS = "active_seconds" @@ -2085,6 +2106,7 @@ class CustomChannelCondition(BaseModel): model_config = ConfigDict( extra="forbid", ) + id: str key: CustomChannelField op: CustomChannelOperator value: Optional[Union[str, list[str]]] = None @@ -2096,7 +2118,8 @@ class CustomChannelRule(BaseModel): ) channel_type: str combiner: FilterLogicalOperator - conditions: list[CustomChannelCondition] + id: str + items: list[CustomChannelCondition] class DataWarehousePersonPropertyFilter(BaseModel):