0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-24 00:47:50 +01:00

Almost there, just need a bit of styling

This commit is contained in:
Robbie Coomber 2024-11-22 13:18:21 +00:00
parent 9eec0e71f9
commit 6e64697b08
13 changed files with 486 additions and 668 deletions

View File

@ -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)

View File

@ -20,7 +20,7 @@ interface ExampleSubItem {
}
interface ExampleItem {
id: UniqueIdentifier
items: ExampleSubItem[]
items?: ExampleSubItem[]
}
let counter = 0

View File

@ -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<T extends VDNDChildItem> {
items: T[]
items?: T[]
id: UniqueIdentifier
}
export interface VerticalNestedDNDProps<ChildItem extends VDNDChildItem, Item extends VNDNDContainerItem<ChildItem>> {
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<ChildItem extends VDNDChildItem, Item extends
const containerItems = items[overId].items
// If a container is matched and it contains items (columns 'A', 'B', 'C')
if (containerItems.length > 0) {
if (containerItems && containerItems.length > 0) {
// Return the closest droppable within that container
overId = closestCenter({
...args,
@ -150,12 +153,12 @@ export function VerticalNestedDND<ChildItem extends VDNDChildItem, Item extends
return id
}
return Object.keys(items).find((key) => 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 extends VDNDChildItem, Item extends
if (!container) {
return -1
}
const childItems = items[container].items
if (!childItems) {
return -1
}
return items[container].items.findIndex((ChildItem) => ChildItem.id === id)
return childItems.findIndex((ChildItem) => ChildItem.id === id)
}
const onDragCancel = (): void => {
@ -235,8 +242,8 @@ export function VerticalNestedDND<ChildItem extends VDNDChildItem, Item extends
if (activeContainerId !== overContainerId) {
setItems((items) => {
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<ChildItem extends VDNDChildItem, Item extends
...overContainer,
items: [
...overItems.slice(0, newIndex),
activeContainer.items[activeIndex],
activeItems[activeIndex],
...overItems.slice(newIndex, overItems.length),
],
}
@ -277,7 +284,7 @@ export function VerticalNestedDND<ChildItem extends VDNDChildItem, Item extends
})
} else if (overId !== active.id) {
setItems((items) => {
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 extends VDNDChildItem, Item extends
const overContainerId = findContainer(overId)
if (overContainerId) {
const activeIndex = items[activeContainerId].items.findIndex(
(ChildItem) => 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<ChildItem extends VDNDChildItem, Item extends
{containers.map((containerId) => (
<DroppableContainer
key={containerId}
items={items[containerId].items}
items={items[containerId].items || []}
onRemove={() => handleRemove(containerId)}
renderContainerItem={renderContainerItem}
containerItemId={containerId}
item={items[containerId]}
onAddChild={handleAddChild}
updateContainerItem={updateContainerItem}
>
<SortableContext items={items[containerId].items} strategy={verticalListSortingStrategy}>
{items[containerId].items.map((value, index) => {
<SortableContext
items={items[containerId].items || []}
strategy={verticalListSortingStrategy}
>
{(items[containerId].items || []).map((value, index) => {
return (
<SortableItem
disabled={isSortingContainer}
@ -375,6 +387,7 @@ export function VerticalNestedDND<ChildItem extends VDNDChildItem, Item extends
containerId={containerId}
getIndex={getIndex}
renderChildItem={renderChildItem}
updateChildItem={updateChildItem}
item={value}
/>
)
@ -407,7 +420,15 @@ export function VerticalNestedDND<ChildItem extends VDNDChildItem, Item extends
if (!item) {
return null
}
return <ChildItem childItemId={id} dragOverlay renderChildItem={renderChildItem} item={item} />
return (
<ChildItem
childItemId={id}
dragOverlay
renderChildItem={renderChildItem}
item={item}
updateChildItem={NOOP}
/>
)
}
function renderContainerDragOverlay(containerId: UniqueIdentifier): JSX.Element | null {
@ -421,10 +442,17 @@ export function VerticalNestedDND<ChildItem extends VDNDChildItem, Item extends
shadow
renderContainerItem={renderContainerItem}
item={item}
onAddChild={() => {}}
onAddChild={NOOP}
updateContainerItem={NOOP}
>
{items[containerId].items.map((item) => (
<ChildItem key={item.id} childItemId={item.id} renderChildItem={renderChildItem} item={item} />
{(items[containerId].items || []).map((item) => (
<ChildItem
key={item.id}
childItemId={item.id}
renderChildItem={renderChildItem}
item={item}
updateChildItem={NOOP}
/>
))}
</Container>
)
@ -455,7 +483,37 @@ export function VerticalNestedDND<ChildItem extends VDNDChildItem, Item extends
...items,
[containerId]: {
...container,
items: [...container.items, newChild],
items: [...(container.items || []), newChild],
},
}
})
}
function updateContainerItem(item: Item): void {
setItems((items) => ({
...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<Item extends VDNDChildItem> {
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<Item extends VDNDChildItem>({
index,
handle,
renderChildItem,
updateChildItem,
item,
}: SortableItemProps<Item>): JSX.Element {
const { setNodeRef, setActivatorNodeRef, listeners, isDragging, isSorting, transform, transition } = useSortable({
@ -511,6 +571,7 @@ function SortableItem<Item extends VDNDChildItem>({
fadeIn={mountedWhileDragging}
listeners={listeners}
renderChildItem={renderChildItem}
updateChildItem={updateChildItem}
item={item}
/>
)
@ -592,7 +653,8 @@ export interface ContainerProps<Item extends VNDNDContainerItem<any>> {
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_<Item extends VNDNDContai
transform,
transition,
renderContainerItem,
updateContainerItem,
item,
...props
}: ContainerProps<Item>,
@ -639,8 +702,13 @@ export const Container = forwardRef(function Container_<Item extends VNDNDContai
>
<div className="flex flex-row justify-between px-2 space-x-2">
<Handle {...handleProps} />
<div>{renderContainerItem ? renderContainerItem(item) : <span>Container {containerItemId}</span>}</div>
<div className="flex-1" />
<div className="flex-1">
{renderContainerItem ? (
renderContainerItem(item, { updateContainerItem })
) : (
<span>Container {containerItemId}</span>
)}
</div>
<Remove onClick={onRemove} />
</div>
{placeholder ? children : <ul className="space-y-2">{children}</ul>}
@ -675,7 +743,8 @@ export interface ChildItemProps<Item extends VDNDChildItem> {
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"
>
<Handle {...handleProps} {...listeners} />
<div>{renderChildItem ? renderChildItem(childItemId) : <span>Item {childItemId}</span>}</div>
<div className="flex-1" />
<div className="flex-1">
{renderChildItem ? renderChildItem(item, { updateChildItem }) : <span>Item {childItemId}</span>}
</div>
<Remove onClick={onRemove} />
</div>
</li>
@ -749,8 +821,10 @@ export function Remove(props: LemonButtonProps): JSX.Element {
export const Handle = forwardRef<HTMLButtonElement, LemonButtonProps>(function Handle_(props, ref) {
return (
<LemonButton type="secondary" fullWidth={false} ref={ref} {...props}>
<IconBuilding />
<LemonButton type="tertiary" fullWidth={false} ref={ref} {...props} className="self-start">
<div>
<IconDragHandle />
</div>
</LemonButton>
)
})

View File

@ -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"

View File

@ -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',
}

View File

@ -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<HTMLUListElement, Props>(({ children, columns = 1, horizontal, style }: Props, ref) => {
// return <ul ref={ref}>{children}</ul>
// })
//
// export function createRange<T = number>(length: number, initializer: (index: number) => any = defaultInitializer): T[] {
// return [...new Array(length)].map((_, index) => initializer(index))
// }
//
export function ChannelType(): JSX.Element {
return <div />
// 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<string>(
// 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 (
// <>
// <p>Set your custom channel type</p>
// <LemonInput
// value={customChannelTypeRules}
// onChange={setCustomChannelTypeRules}
// placeholder="Enter JSON array of custom channel type rules"
// />
// <div className="mt-4">
// <LemonButton type="primary" onClick={() => handleChange(customChannelTypeRules)}>
// Save
// </LemonButton>
// </div>
// </>
// )
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<CustomChannelRule[]>(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 (
<ChannelTypeEditor
handleChange={debouncedHandleChange}
initialCustomChannelTypeRules={customChannelTypeRules}
/>
)
}
export interface ChannelTypeEditorProps {
handleChange: (rules: CustomChannelRule[]) => void
initialCustomChannelTypeRules: CustomChannelRule[]
}
export function ChannelTypeEditor({
handleChange,
initialCustomChannelTypeRules,
}: ChannelTypeEditorProps): JSX.Element {
return (
<VerticalNestedDND<CustomChannelCondition, CustomChannelRule>
initialItems={initialCustomChannelTypeRules}
renderContainerItem={(rule, { updateContainerItem }) => {
return (
<div className="flex flex-col">
<div>
Set Channel type to{' '}
<LemonInputSelect
mode="single"
allowCustomValues={true}
value={[rule.channel_type]}
onChange={(channelType) =>
updateContainerItem({
...rule,
channel_type: channelType[0],
})
}
/>
</div>
<div>
{rule.items.length <= 1 ? (
'When'
) : (
<div>
When{' '}
<LemonSelect
value={rule.combiner}
options={combinerOptions}
onChange={(combiner) => updateContainerItem({ ...rule, combiner })}
/>
</div>
)}
</div>
</div>
)
}}
renderChildItem={(rule, { updateChildItem }) => {
return (
<div className="w-full space-y-2">
<div className="flex flex-row space-x-2">
<LemonSelect<CustomChannelField>
value={rule.key}
options={keyOptions}
onChange={(key) => updateChildItem({ ...rule, key })}
/>
<LemonSelect<CustomChannelOperator>
value={rule.op}
options={opOptions}
onChange={(op) => updateChildItem({ ...rule, op })}
/>
</div>
{isNullary(rule.op) ? null : (
<PropertyValue
key={rule.key}
propertyKey={keyToSessionproperty(rule.key)}
type={PropertyFilterType.Session}
onSet={(propertyValue: any) => {
updateChildItem({ ...rule, value: propertyValue })
}}
operator={opToPropertyOperator(rule.op)}
value={rule.value}
/>
)}
</div>
)
}}
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<CustomChannelRule[]>(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 (
// <div className="flex flex-col gap-2">
// <div className="flex items-center gap-2 flex-wrap">
// <Sortable strategy={verticalListSortingStrategy} />
// </div>
// <div>
// <button>Add</button>
// </div>
// </div>
// )
// }
//
// 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<Active, 'id'> | 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<UniqueIdentifier[]>(
// // @ts-expect-error
// () => initialItems ?? createRange<UniqueIdentifier>(itemCount, (index) => index + 1)
// )
// const [activeId, setActiveId] = useState<UniqueIdentifier | null>(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 (
// <DndContext
// accessibility={{
// announcements,
// screenReaderInstructions,
// }}
// sensors={sensors}
// collisionDetection={collisionDetection}
// onDragStart={({ active }) => {
// 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}
// >
// <Wrapper style={style} center>
// <SortableContext items={items} strategy={strategy}>
// <Container>
// {items.map((value, index) => (
// <SortableItem
// key={value}
// id={value}
// handle={handle}
// index={index}
// style={getItemStyles}
// wrapperStyle={wrapperStyle}
// disabled={isDisabled(value)}
// renderItem={renderItem}
// onRemove={handleRemove}
// animateLayoutChanges={animateLayoutChanges}
// useDragOverlay={useDragOverlay}
// getNewIndex={getNewIndex}
// />
// ))}
// </Container>
// </SortableContext>
// </Wrapper>
// {useDragOverlay
// ? createPortal(
// <DragOverlay adjustScale={adjustScale} dropAnimation={dropAnimation}>
// {activeId ? (
// <Item
// value={items[activeIndex]}
// handle={handle}
// renderItem={renderItem}
// wrapperStyle={wrapperStyle({
// active: { id: activeId },
// index: activeIndex,
// isDragging: true,
// id: items[activeIndex],
// })}
// style={getItemStyles({
// id: items[activeIndex],
// index: activeIndex,
// isSorting: activeId !== null,
// isDragging: true,
// overIndex: -1,
// isDragOverlay: true,
// })}
// dragOverlay
// />
// ) : null}
// </DragOverlay>,
// document.body
// )
// : null}
// </DndContext>
// )
// }
//
// 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 (
// <Item
// ref={setNodeRef}
// value={id}
// disabled={disabled}
// dragging={isDragging}
// sorting={isSorting}
// handle={handle}
// handleProps={
// handle
// ? {
// ref: setActivatorNodeRef,
// }
// : undefined
// }
// renderItem={renderItem}
// index={index}
// style={style({
// index,
// id,
// isDragging,
// isSorting,
// overIndex,
// })}
// onRemove={onRemove ? () => 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<HTMLElement>;
// style: React.CSSProperties | undefined;
// transform: Props['transform'];
// transition: Props['transition'];
// value: Props['value'];
// }): React.ReactElement;
// }
//
// export const Item = React.memo(
// React.forwardRef<HTMLLIElement, Props>(
// (
// {
// 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,
// })
// ) : (
// <li
// className={classNames(
// styles.Wrapper,
// fadeIn && styles.fadeIn,
// sorting && styles.sorting,
// dragOverlay && styles.dragOverlay
// )}
// style={
// {
// ...wrapperStyle,
// transition: [transition, wrapperStyle?.transition]
// .filter(Boolean)
// .join(', '),
// '--translate-x': transform
// ? `${Math.round(transform.x)}px`
// : undefined,
// '--translate-y': transform
// ? `${Math.round(transform.y)}px`
// : undefined,
// '--scale-x': transform?.scaleX
// ? `${transform.scaleX}`
// : undefined,
// '--scale-y': transform?.scaleY
// ? `${transform.scaleY}`
// : undefined,
// '--index': index,
// '--color': color,
// } as React.CSSProperties
// }
// ref={ref}
// >
// <div
// className={classNames(
// styles.Item,
// dragging && styles.dragging,
// handle && styles.withHandle,
// dragOverlay && styles.dragOverlay,
// disabled && styles.disabled,
// color && styles.color
// )}
// style={style}
// data-cypress="draggable-item"
// {...(!handle ? listeners : undefined)}
// {...props}
// tabIndex={!handle ? 0 : undefined}
// >
// {value}
// <span className={styles.Actions}>
// {onRemove ? (
// <Remove className={styles.Remove} onClick={onRemove} />
// ) : null}
// {handle ? <Handle {...handleProps} {...listeners} /> : null}
// </span>
// </div>
// </li>
// );
// }
// )
// );

View File

@ -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",

View File

@ -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'

View File

@ -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:

View File

@ -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))]

View File

@ -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"],
]

View File

@ -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()

View File

@ -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):