feat(ai): Render recordings filters ui_payload (#40814)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
@@ -160,7 +160,7 @@ describe('the activity log logic', () => {
|
||||
|
||||
let renderedExtendedDescription = render(<>{actual[0].extendedDescription}</>).container
|
||||
expect(renderedExtendedDescription).toHaveTextContent(
|
||||
"Query summaryAShowing \"Views\"Pageviewcounted by total countwhere event'sBrowser= equals Chromeand person belongs to cohortUser in ID 2FiltersEvent'sCurrent URL= equals https://hedgebox.net/files/or event'sCountry code= equals US or AUBreakdown byCountry code"
|
||||
"QueryACounting \"Views\"Pageviewby total countwhere event'sBrowser= equals Chromeand person belongs to cohortUser in ID 2FiltersEvent'sCurrent URL= equals https://hedgebox.net/files/or event'sCountry code= equals US or AUBreakdown byCountry code"
|
||||
)
|
||||
;(insightMock.after.breakdownFilter as BreakdownFilter) = {
|
||||
breakdowns: [
|
||||
@@ -183,7 +183,7 @@ describe('the activity log logic', () => {
|
||||
|
||||
renderedExtendedDescription = render(<>{actual[0].extendedDescription}</>).container
|
||||
expect(renderedExtendedDescription).toHaveTextContent(
|
||||
"Query summaryAShowing \"Views\"Pageviewcounted by total countwhere event'sBrowser= equals Chromeand person belongs to cohortUser in ID 2FiltersEvent'sCurrent URL= equals https://hedgebox.net/files/or event'sCountry code= equals US or AUBreakdown byCountry codeSession duration"
|
||||
"QueryACounting \"Views\"Pageviewby total countwhere event'sBrowser= equals Chromeand person belongs to cohortUser in ID 2FiltersEvent'sCurrent URL= equals https://hedgebox.net/files/or event'sCountry code= equals US or AUBreakdown byCountry code"
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useValues } from 'kea'
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
PROPERTY_FILTER_TYPE_TO_TAXONOMIC_FILTER_GROUP_TYPE,
|
||||
formatPropertyLabel,
|
||||
isAnyPropertyfilter,
|
||||
isCohortPropertyFilter,
|
||||
isPropertyFilterWithOperator,
|
||||
} from 'lib/components/PropertyFilters/utils'
|
||||
import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo'
|
||||
import { Link } from 'lib/lemon-ui/Link'
|
||||
import { allOperatorsMapping, capitalizeFirstLetter } from 'lib/utils'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { cohortsModel } from '~/models/cohortsModel'
|
||||
import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel'
|
||||
import {
|
||||
ActionFilter,
|
||||
FilterLogicalOperator,
|
||||
PropertyFilterBaseValue,
|
||||
PropertyGroupFilter,
|
||||
UniversalFiltersGroup,
|
||||
UniversalFiltersGroupValue,
|
||||
} from '~/types'
|
||||
|
||||
function isActionFilter(filter: UniversalFiltersGroupValue): filter is ActionFilter {
|
||||
return (filter as ActionFilter).type !== undefined && 'id' in filter
|
||||
}
|
||||
|
||||
function isUniversalFiltersGroup(value: UniversalFiltersGroupValue): value is UniversalFiltersGroup {
|
||||
return (value as UniversalFiltersGroup).type !== undefined && (value as UniversalFiltersGroup).values !== undefined
|
||||
}
|
||||
|
||||
export function CompactUniversalFiltersDisplay({
|
||||
groupFilter,
|
||||
embedded,
|
||||
}: {
|
||||
groupFilter: UniversalFiltersGroup | PropertyGroupFilter | null
|
||||
embedded?: boolean
|
||||
}): JSX.Element {
|
||||
const { cohortsById } = useValues(cohortsModel)
|
||||
const { formatPropertyValueForDisplay } = useValues(propertyDefinitionsModel)
|
||||
|
||||
if (!groupFilter || !groupFilter.values.length) {
|
||||
return <i>None</i>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupFilter.values.map((filterOrGroup, index) => {
|
||||
if (isUniversalFiltersGroup(filterOrGroup)) {
|
||||
// Nested group
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && (
|
||||
<em className="text-[11px] font-semibold leading-5">
|
||||
{groupFilter.type === FilterLogicalOperator.Or ? 'OR' : 'AND'}
|
||||
</em>
|
||||
)}
|
||||
<CompactUniversalFiltersDisplay groupFilter={filterOrGroup} embedded={embedded} />
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
if (isActionFilter(filterOrGroup)) {
|
||||
return (
|
||||
<div key={index} className="SeriesDisplay__condition">
|
||||
<span>
|
||||
{embedded && index === 0 ? 'where ' : null}
|
||||
{index > 0 ? (groupFilter.type === FilterLogicalOperator.Or ? 'or ' : 'and ') : null}
|
||||
Performed action
|
||||
<Link
|
||||
to={urls.action(filterOrGroup.id as number)}
|
||||
className="SeriesDisplay__raw-name SeriesDisplay__raw-name--action mx-1"
|
||||
title="Action"
|
||||
>
|
||||
{filterOrGroup.name || `Action ${filterOrGroup.id}`}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Property filter
|
||||
const isFirstFilterOverall = index === 0
|
||||
|
||||
return (
|
||||
<div key={index} className="SeriesDisplay__condition">
|
||||
<span>
|
||||
{isFirstFilterOverall && embedded ? 'where ' : null}
|
||||
{index > 0 ? (
|
||||
<strong>{groupFilter.type === FilterLogicalOperator.Or ? 'or ' : 'and '}</strong>
|
||||
) : null}
|
||||
{isCohortPropertyFilter(filterOrGroup) ? (
|
||||
<>
|
||||
{isFirstFilterOverall && !embedded ? 'Person' : 'person'} belongs to cohort
|
||||
<span className="SeriesDisplay__raw-name">
|
||||
{formatPropertyLabel(
|
||||
filterOrGroup,
|
||||
cohortsById,
|
||||
(s) =>
|
||||
formatPropertyValueForDisplay(filterOrGroup.key, s)?.toString() || '?'
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isFirstFilterOverall && !embedded
|
||||
? capitalizeFirstLetter(filterOrGroup.type || 'event')
|
||||
: filterOrGroup.type || 'event'}
|
||||
's
|
||||
<span className="SeriesDisplay__raw-name">
|
||||
{isAnyPropertyfilter(filterOrGroup) && filterOrGroup.key && (
|
||||
<PropertyKeyInfo
|
||||
value={filterOrGroup.key}
|
||||
type={
|
||||
PROPERTY_FILTER_TYPE_TO_TAXONOMIC_FILTER_GROUP_TYPE[
|
||||
filterOrGroup.type
|
||||
]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<em>
|
||||
{
|
||||
allOperatorsMapping[
|
||||
(isPropertyFilterWithOperator(filterOrGroup) &&
|
||||
filterOrGroup.operator) ||
|
||||
'exact'
|
||||
]
|
||||
}
|
||||
</em>{' '}
|
||||
{isAnyPropertyfilter(filterOrGroup) &&
|
||||
(Array.isArray(filterOrGroup.value) ? (
|
||||
filterOrGroup.value.map((subValue, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<code className="SeriesDisplay__value">{subValue}</code>
|
||||
{index <
|
||||
(filterOrGroup.value as PropertyFilterBaseValue[]).length - 1 &&
|
||||
' or '}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : filterOrGroup.value != undefined ? (
|
||||
<code className="SeriesDisplay__value">{filterOrGroup.value}</code>
|
||||
) : null)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -69,38 +69,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.InsightDetails__query {
|
||||
padding: 0.375rem 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
border-width: 1px;
|
||||
border-radius: var(--radius);
|
||||
|
||||
.LemonRow {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.InsightDetails__formula {
|
||||
code {
|
||||
margin-left: 0.375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.InsightDetails__series {
|
||||
margin: -0.125rem 0;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.LemonDivider {
|
||||
width: calc(100% - 1.5rem);
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.InsightDetails__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -121,13 +89,21 @@
|
||||
}
|
||||
|
||||
.SeriesDisplay {
|
||||
line-height: 1.5rem;
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: start;
|
||||
min-height: 1.5rem;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.SeriesDisplay__raw-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
@@ -135,38 +111,51 @@
|
||||
color: var(--color-text-secondary);
|
||||
vertical-align: middle;
|
||||
background: var(--color-accent-highlight-secondary);
|
||||
border-radius: var(--radius);
|
||||
|
||||
&.SeriesDisplay__raw-name--action,
|
||||
&.SeriesDisplay__raw-name--event {
|
||||
padding: 0.25rem;
|
||||
|
||||
&::before {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
margin-right: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1rem;
|
||||
color: var(--color-bg-surface-primary);
|
||||
text-align: center;
|
||||
background: var(--color-accent);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
border: 1px solid var(--color-accent-highlight-secondary-border);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&.SeriesDisplay__raw-name--event::before,
|
||||
&.SeriesDisplay__raw-name--action::before {
|
||||
content: 'A';
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: calc(100% + 2px);
|
||||
margin-top: -1px;
|
||||
margin-right: 0.25rem;
|
||||
margin-left: calc(-0.25rem - 1px); // Align to overlay the container's border on the left
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1rem;
|
||||
color: var(--color-bg-surface-primary);
|
||||
text-align: center;
|
||||
background: var(--color-accent);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
&.SeriesDisplay__raw-name--event::before {
|
||||
content: 'E';
|
||||
}
|
||||
|
||||
&.SeriesDisplay__raw-name--action::before {
|
||||
content: 'A';
|
||||
}
|
||||
}
|
||||
|
||||
.SeriesDisplay__value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.125rem;
|
||||
background: var(--color-bg-surface-secondary);
|
||||
border-width: 1px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.SeriesDisplay__condition {
|
||||
display: flex;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.SeriesDisplay__arrow {
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { useValues } from 'kea'
|
||||
import React from 'react'
|
||||
|
||||
import { IconCalculator, IconCalendar, IconCode2, IconFilter, IconPencil, IconSort, IconUser } from '@posthog/icons'
|
||||
import { Lettermark, LettermarkColor } from '@posthog/lemon-ui'
|
||||
|
||||
import { CodeSnippet, Language } from 'lib/components/CodeSnippet'
|
||||
import {
|
||||
PROPERTY_FILTER_TYPE_TO_TAXONOMIC_FILTER_GROUP_TYPE,
|
||||
convertPropertiesToPropertyGroup,
|
||||
formatPropertyLabel,
|
||||
isAnyPropertyfilter,
|
||||
isCohortPropertyFilter,
|
||||
isPropertyFilterWithOperator,
|
||||
} from 'lib/components/PropertyFilters/utils'
|
||||
import { convertPropertiesToPropertyGroup } from 'lib/components/PropertyFilters/utils'
|
||||
import { SeriesLetter } from 'lib/components/SeriesGlyph'
|
||||
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
|
||||
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
|
||||
import { LemonRow } from 'lib/lemon-ui/LemonRow'
|
||||
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
|
||||
import { Link } from 'lib/lemon-ui/Link'
|
||||
import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture'
|
||||
import { IconCalculate } from 'lib/lemon-ui/icons'
|
||||
import { allOperatorsMapping, capitalizeFirstLetter, dateFilterToText } from 'lib/utils'
|
||||
import { capitalizeFirstLetter, dateFilterToText } from 'lib/utils'
|
||||
import { BreakdownTag } from 'scenes/insights/filters/BreakdownFilter/BreakdownTag'
|
||||
import { humanizePathsEventTypes } from 'scenes/insights/utils'
|
||||
import { QUERY_TYPES_METADATA } from 'scenes/saved-insights/SavedInsights'
|
||||
import { MathCategory, MathDefinition, apiValueToMathType, mathsLogic } from 'scenes/trends/mathsLogic'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { cohortsModel } from '~/models/cohortsModel'
|
||||
import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel'
|
||||
import {
|
||||
AnyEntityNode,
|
||||
BreakdownFilter,
|
||||
@@ -62,98 +56,25 @@ import { AnyPropertyFilter, FilterLogicalOperator, PropertyGroupFilter, UserBasi
|
||||
|
||||
import { PropertyKeyInfo } from '../../PropertyKeyInfo'
|
||||
import { TZLabel } from '../../TZLabel'
|
||||
import { CompactUniversalFiltersDisplay } from './CompactUniversalFiltersDisplay'
|
||||
|
||||
function CompactPropertyFiltersDisplay({
|
||||
groupFilter,
|
||||
embedded,
|
||||
export function InsightDetailSectionDisplay({
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
groupFilter: PropertyGroupFilter | null
|
||||
embedded?: boolean
|
||||
icon: React.ReactNode
|
||||
label: string | JSX.Element
|
||||
children: React.ReactNode
|
||||
}): JSX.Element {
|
||||
const { cohortsById } = useValues(cohortsModel)
|
||||
const { formatPropertyValueForDisplay } = useValues(propertyDefinitionsModel)
|
||||
|
||||
const areAnyFiltersPresent = !!groupFilter && groupFilter.values.flatMap((subValues) => subValues.values).length > 0
|
||||
|
||||
if (!areAnyFiltersPresent) {
|
||||
return <i>None</i>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupFilter.values.map(({ values: subValues, type: subType }, subIndex) => (
|
||||
<React.Fragment key={subIndex}>
|
||||
{subIndex === 0 ? null : (
|
||||
<em className="text-[11px] font-semibold">
|
||||
{groupFilter.type === FilterLogicalOperator.Or ? 'OR' : 'AND'}
|
||||
</em>
|
||||
)}
|
||||
{subValues.map((leafFilter, leafIndex) => {
|
||||
const isFirstFilterWithinSubgroup = leafIndex === 0
|
||||
const isFirstFilterOverall = isFirstFilterWithinSubgroup && subIndex === 0
|
||||
|
||||
return (
|
||||
<div key={leafIndex} className="SeriesDisplay__condition">
|
||||
<span>
|
||||
{isFirstFilterWithinSubgroup
|
||||
? embedded
|
||||
? 'where '
|
||||
: null
|
||||
: subType === FilterLogicalOperator.Or
|
||||
? 'or '
|
||||
: 'and '}
|
||||
{isCohortPropertyFilter(leafFilter) ? (
|
||||
<>
|
||||
{isFirstFilterOverall && !embedded ? 'Person' : 'person'} belongs to cohort
|
||||
<span className="SeriesDisplay__raw-name">
|
||||
{formatPropertyLabel(
|
||||
leafFilter,
|
||||
cohortsById,
|
||||
(s) =>
|
||||
formatPropertyValueForDisplay(leafFilter.key, s)?.toString() ||
|
||||
'?'
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isFirstFilterOverall && !embedded
|
||||
? capitalizeFirstLetter(leafFilter.type || 'event')
|
||||
: leafFilter.type || 'event'}
|
||||
's
|
||||
<span className="SeriesDisplay__raw-name">
|
||||
{isAnyPropertyfilter(leafFilter) && leafFilter.key && (
|
||||
<PropertyKeyInfo
|
||||
value={leafFilter.key}
|
||||
type={
|
||||
PROPERTY_FILTER_TYPE_TO_TAXONOMIC_FILTER_GROUP_TYPE[
|
||||
leafFilter.type
|
||||
]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{
|
||||
allOperatorsMapping[
|
||||
(isPropertyFilterWithOperator(leafFilter) && leafFilter.operator) ||
|
||||
'exact'
|
||||
]
|
||||
}{' '}
|
||||
<b>
|
||||
{isAnyPropertyfilter(leafFilter) &&
|
||||
(Array.isArray(leafFilter.value)
|
||||
? leafFilter.value.join(' or ')
|
||||
: leafFilter.value)}
|
||||
</b>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
<section className="flex items-start gap-2 text-xs">
|
||||
<div className="flex text-muted-alt mt-px flex-shrink-0 text-sm">{icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-muted-alt mb-0.5">{label}</div>
|
||||
<div className="leading-6">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -201,29 +122,18 @@ function SeriesDisplay({
|
||||
] as MathDefinition | undefined
|
||||
|
||||
return (
|
||||
<LemonRow
|
||||
fullWidth
|
||||
className="SeriesDisplay"
|
||||
icon={<SeriesLetter seriesIndex={seriesIndex} hasBreakdown={hasBreakdown} />}
|
||||
extendedContent={
|
||||
series.properties &&
|
||||
series.properties.length > 0 && (
|
||||
<CompactPropertyFiltersDisplay
|
||||
groupFilter={{
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [{ type: FilterLogicalOperator.And, values: series.properties }],
|
||||
}}
|
||||
embedded
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{isFunnelsQuery(query) ? 'Performed' : 'Showing'}
|
||||
<div className="SeriesDisplay">
|
||||
{isFunnelsQuery(query) ? (
|
||||
<Lettermark name={seriesIndex + 1} color={LettermarkColor.Gray} className="mt-px" />
|
||||
) : (
|
||||
<SeriesLetter seriesIndex={seriesIndex} hasBreakdown={hasBreakdown} className="mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
{isFunnelsQuery(query) ? 'Performed' : 'Counting'}
|
||||
<EntityDisplay entity={series} />
|
||||
{!isFunnelsQuery(query) && (
|
||||
<span className="leading-none">
|
||||
counted by{' '}
|
||||
<>
|
||||
by{' '}
|
||||
{mathDefinition?.category === MathCategory.HogQLExpression ? (
|
||||
<code>{series.math_hogql}</code>
|
||||
) : (
|
||||
@@ -243,10 +153,19 @@ function SeriesDisplay({
|
||||
<b>{mathDefinition?.name.toLowerCase()}</b>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</LemonRow>
|
||||
{series.properties && series.properties.length > 0 && (
|
||||
<CompactUniversalFiltersDisplay
|
||||
groupFilter={{
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [{ type: FilterLogicalOperator.And, values: series.properties }],
|
||||
}}
|
||||
embedded
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,18 +175,20 @@ function PathsSummary({ query }: { query: PathsQuery }): JSX.Element {
|
||||
return (
|
||||
<div className="SeriesDisplay">
|
||||
<div>
|
||||
User paths based on <b>{humanizePathsEventTypes(includeEventTypes).join(' and ')}</b>
|
||||
<div>
|
||||
User paths based on <b>{humanizePathsEventTypes(includeEventTypes).join(' and ')}</b>
|
||||
</div>
|
||||
{startPoint && (
|
||||
<div>
|
||||
starting at <b>{startPoint}</b>
|
||||
</div>
|
||||
)}
|
||||
{endPoint && (
|
||||
<div>
|
||||
ending at <b>{endPoint}</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{startPoint && (
|
||||
<div>
|
||||
starting at <b>{startPoint}</b>
|
||||
</div>
|
||||
)}
|
||||
{endPoint && (
|
||||
<div>
|
||||
ending at <b>{endPoint}</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -305,7 +226,7 @@ function RetentionSummary({ query }: { query: RetentionQuery }): JSX.Element {
|
||||
{query.retentionFilter.period?.toLocaleLowerCase() ?? 'day'}s
|
||||
</strong>
|
||||
<br />
|
||||
and came back to perform
|
||||
<strong>and</strong> who came back to perform
|
||||
<EntityDisplay
|
||||
entity={
|
||||
query.retentionFilter.returningEntity?.type === 'actions'
|
||||
@@ -321,7 +242,6 @@ function RetentionSummary({ query }: { query: RetentionQuery }): JSX.Element {
|
||||
}
|
||||
}
|
||||
/>
|
||||
in any of the next periods
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -333,37 +253,33 @@ export function SeriesSummary({
|
||||
query: InsightQueryNode | HogQLQuery
|
||||
heading?: JSX.Element | null
|
||||
}): JSX.Element {
|
||||
const IconComponent = QUERY_TYPES_METADATA[query.kind].icon
|
||||
|
||||
return (
|
||||
<section>
|
||||
{heading !== null && <h5>{heading || 'Query summary'}</h5>}
|
||||
<InsightDetailSectionDisplay icon={<IconComponent />} label={heading !== null ? heading || 'Query' : ''}>
|
||||
{isHogQLQuery(query) ? (
|
||||
<CodeSnippet language={Language.SQL} maxLinesWithoutExpansion={8} compact>
|
||||
{query.query}
|
||||
</CodeSnippet>
|
||||
) : (
|
||||
<div className="InsightDetails__query">
|
||||
<>
|
||||
{isTrendsQuery(query) && <FormulaSummary query={query} />}
|
||||
<div className="InsightDetails__series">
|
||||
{isPathsQuery(query) ? (
|
||||
<PathsSummary query={query} />
|
||||
) : isRetentionQuery(query) ? (
|
||||
<RetentionSummary query={query} />
|
||||
) : isInsightQueryWithSeries(query) ? (
|
||||
<>
|
||||
{query.series.map((_entity, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index !== 0 && <LemonDivider className="my-1" />}
|
||||
<SeriesDisplay query={query} seriesIndex={index} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<i>Query summary is not available for {(query as Node).kind} yet</i>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isPathsQuery(query) ? (
|
||||
<PathsSummary query={query} />
|
||||
) : isRetentionQuery(query) ? (
|
||||
<RetentionSummary query={query} />
|
||||
) : isInsightQueryWithSeries(query) ? (
|
||||
<>
|
||||
{query.series.map((_entity, index) => (
|
||||
<SeriesDisplay key={index} query={query} seriesIndex={index} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<i>Query summary is not available for {(query as Node).kind} yet</i>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</InsightDetailSectionDisplay>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -378,22 +294,22 @@ export function FormulaSummary({ query }: { query: TrendsQuery }): JSX.Element |
|
||||
|
||||
return (
|
||||
<>
|
||||
{formulaNodes.map((node) => (
|
||||
<>
|
||||
<LemonRow className="InsightDetails__formula" icon={<IconCalculate />} fullWidth>
|
||||
{node.custom_name ? (
|
||||
<span>
|
||||
Formula <b>"{node.custom_name}"</b>: <code>{node.formula}</code>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Formula: <code>{node.formula}</code>
|
||||
</span>
|
||||
{formulaNodes.map((node, index) => (
|
||||
<div className="SeriesDisplay" key={index}>
|
||||
<IconCalculate className="text-xl m-px text-text-secondary-3000" />
|
||||
<span>
|
||||
Formula
|
||||
{node.custom_name && (
|
||||
<>
|
||||
{' '}
|
||||
<b>{node.custom_name}</b>
|
||||
</>
|
||||
)}
|
||||
</LemonRow>
|
||||
<LemonDivider />
|
||||
</>
|
||||
: <code>{node.formula}</code>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<LemonDivider className="mt-1 mb-2" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -404,12 +320,9 @@ export function PropertiesSummary({
|
||||
properties: PropertyGroupFilter | AnyPropertyFilter[] | undefined | null
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<section>
|
||||
<h5>Filters</h5>
|
||||
<div>
|
||||
<CompactPropertyFiltersDisplay groupFilter={convertPropertiesToPropertyGroup(properties)} />
|
||||
</div>
|
||||
</section>
|
||||
<InsightDetailSectionDisplay icon={<IconFilter />} label="Filters">
|
||||
<CompactUniversalFiltersDisplay groupFilter={convertPropertiesToPropertyGroup(properties)} />
|
||||
</InsightDetailSectionDisplay>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -425,29 +338,25 @@ export function VariablesSummary({
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h5>Variables</h5>
|
||||
<div>
|
||||
{Object.entries(variables).map(([key, variable]) => {
|
||||
const overrideValue = variablesOverride?.[key]?.value
|
||||
const hasOverride = overrideValue !== undefined && overrideValue !== variable.value
|
||||
<InsightDetailSectionDisplay icon={<IconCode2 />} label="Variables">
|
||||
{Object.entries(variables).map(([key, variable]) => {
|
||||
const overrideValue = variablesOverride?.[key]?.value
|
||||
const hasOverride = overrideValue !== undefined && overrideValue !== variable.value
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span>
|
||||
{variable.code_name}:{' '}
|
||||
{variable.value ? <strong>{variable.value}</strong> : <em>null</em>}
|
||||
</span>
|
||||
{hasOverride && (
|
||||
<LemonTag type="highlight">
|
||||
Overridden: {overrideValue ? <strong>{overrideValue}</strong> : <em>null</em>}
|
||||
</LemonTag>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span>
|
||||
{variable.code_name}: {variable.value ? <strong>{variable.value}</strong> : <em>null</em>}
|
||||
</span>
|
||||
{hasOverride && (
|
||||
<LemonTag type="highlight">
|
||||
Overridden: {overrideValue ? <strong>{overrideValue}</strong> : <em>null</em>}
|
||||
</LemonTag>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</InsightDetailSectionDisplay>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -464,26 +373,39 @@ export function BreakdownSummary({
|
||||
}: {
|
||||
breakdownFilter: BreakdownFilter | null | undefined
|
||||
}): JSX.Element | null {
|
||||
if (!isValidBreakdown(breakdownFilter)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = Array.isArray(breakdownFilter.breakdowns) ? (
|
||||
<>
|
||||
{breakdownFilter.breakdowns.map((b) => (
|
||||
<BreakdownTag
|
||||
key={`${b.type}-${b.property}`}
|
||||
breakdown={b.property}
|
||||
breakdownType={b.type}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : breakdownFilter.breakdown ? (
|
||||
<>
|
||||
{(Array.isArray(breakdownFilter.breakdown) ? breakdownFilter.breakdown : [breakdownFilter.breakdown]).map(
|
||||
(b) => (
|
||||
<BreakdownTag key={b} breakdown={b} breakdownType={breakdownFilter.breakdown_type} size="small" />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h5>Breakdown by</h5>
|
||||
<div>
|
||||
{!isValidBreakdown(breakdownFilter) ? (
|
||||
<i>None</i>
|
||||
) : Array.isArray(breakdownFilter.breakdowns) ? (
|
||||
breakdownFilter.breakdowns.map((b) => (
|
||||
<BreakdownTag key={`${b.type}-${b.property}`} breakdown={b.property} breakdownType={b.type} />
|
||||
))
|
||||
) : (
|
||||
breakdownFilter.breakdown &&
|
||||
(Array.isArray(breakdownFilter.breakdown)
|
||||
? breakdownFilter.breakdown
|
||||
: [breakdownFilter.breakdown].map((b) => (
|
||||
<BreakdownTag key={b} breakdown={b} breakdownType={breakdownFilter.breakdown_type} />
|
||||
)))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<InsightDetailSectionDisplay icon={<IconSort />} label="Breakdown by">
|
||||
{content}
|
||||
</InsightDetailSectionDisplay>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -494,11 +416,14 @@ export function DateRangeSummary({
|
||||
dateFrom: string | null | undefined
|
||||
dateTo: string | null | undefined
|
||||
}): JSX.Element | null {
|
||||
const dateFilterText = dateFilterToText(dateFrom, dateTo, null)
|
||||
if (!dateFilterText) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<h5>Date range</h5>
|
||||
<div>{dateFilterToText(dateFrom, dateTo, null)}</div>
|
||||
</section>
|
||||
<InsightDetailSectionDisplay icon={<IconCalendar />} label="Date range">
|
||||
<div className="font-medium">{dateFilterText}</div>
|
||||
</InsightDetailSectionDisplay>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -519,10 +444,11 @@ export const InsightDetails = React.memo(
|
||||
{ query, footerInfo, variablesOverride },
|
||||
ref
|
||||
): JSX.Element {
|
||||
// TODO: Implement summaries for HogQL query insights
|
||||
return (
|
||||
<div className="InsightDetails" ref={ref}>
|
||||
{isInsightVizNode(query) || isDataVisualizationNode(query) || isDataTableNodeWithHogQLQuery(query) ? (
|
||||
<div className="InsightDetails space-y-2" ref={ref}>
|
||||
{(isInsightVizNode(query) ||
|
||||
isDataVisualizationNode(query) ||
|
||||
isDataTableNodeWithHogQLQuery(query)) && (
|
||||
<>
|
||||
<SeriesSummary query={query.source} />
|
||||
<VariablesSummary
|
||||
@@ -536,32 +462,27 @@ export const InsightDetails = React.memo(
|
||||
/>
|
||||
<InsightBreakdownSummary query={query.source} />
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
{footerInfo && (
|
||||
<div className="InsightDetails__footer">
|
||||
<div>
|
||||
<h5>Created by</h5>
|
||||
<section>
|
||||
<ProfilePicture user={footerInfo.created_by} showName size="md" />{' '}
|
||||
<>
|
||||
<InsightDetailSectionDisplay icon={<IconUser />} label="Created by">
|
||||
<div className="flex items-center py-px gap-1.5">
|
||||
<ProfilePicture user={footerInfo.created_by} showName size="sm" />
|
||||
<TZLabel time={footerInfo.created_at} />
|
||||
</section>
|
||||
</div>
|
||||
<div>
|
||||
<h5>Last modified by</h5>
|
||||
<section>
|
||||
<ProfilePicture user={footerInfo.last_modified_by} showName size="md" />{' '}
|
||||
<TZLabel time={footerInfo.last_modified_at} />
|
||||
</section>
|
||||
</div>
|
||||
{footerInfo.last_refresh && (
|
||||
<div>
|
||||
<h5>Last computed</h5>
|
||||
<section>
|
||||
<TZLabel time={footerInfo.last_refresh} />
|
||||
</section>
|
||||
</div>
|
||||
</InsightDetailSectionDisplay>
|
||||
<InsightDetailSectionDisplay icon={<IconPencil />} label="Last modified by">
|
||||
<div className="flex items-center py-px gap-1.5">
|
||||
<ProfilePicture user={footerInfo.last_modified_by} showName size="sm" />
|
||||
<TZLabel time={footerInfo.last_modified_at} />
|
||||
</div>
|
||||
</InsightDetailSectionDisplay>
|
||||
{footerInfo.last_refresh && (
|
||||
<InsightDetailSectionDisplay icon={<IconCalculator />} label="Last computed">
|
||||
<TZLabel time={footerInfo.last_refresh} />
|
||||
</InsightDetailSectionDisplay>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import { Meta, StoryFn } from '@storybook/react'
|
||||
|
||||
import { FilterLogicalOperator, PropertyFilterType, PropertyOperator, RecordingUniversalFilters } from '~/types'
|
||||
|
||||
import { RecordingsUniversalFiltersDisplay as RecordingsUniversalFiltersDisplayComponent } from './RecordingsUniversalFiltersDisplay'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Cards/Recordings Universal Filters Display',
|
||||
component: RecordingsUniversalFiltersDisplayComponent,
|
||||
}
|
||||
export default meta
|
||||
|
||||
const Template: StoryFn<{ filters: RecordingUniversalFilters }> = ({ filters }) => {
|
||||
return (
|
||||
<div className="bg-surface-primary w-[24rem] p-4 rounded">
|
||||
<RecordingsUniversalFiltersDisplayComponent filters={filters} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BasicDateRange = Template.bind({})
|
||||
BasicDateRange.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomDateRange = Template.bind({})
|
||||
CustomDateRange.args = {
|
||||
filters: {
|
||||
date_from: '2024-01-01',
|
||||
date_to: '2024-01-31',
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDurationFilter = Template.bind({})
|
||||
WithDurationFilter.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [
|
||||
{
|
||||
type: PropertyFilterType.Recording,
|
||||
key: 'duration',
|
||||
value: 60,
|
||||
operator: PropertyOperator.GreaterThan,
|
||||
},
|
||||
],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithMultipleDurationFilters = Template.bind({})
|
||||
WithMultipleDurationFilters.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [
|
||||
{
|
||||
type: PropertyFilterType.Recording,
|
||||
key: 'duration',
|
||||
value: 30,
|
||||
operator: PropertyOperator.GreaterThan,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Recording,
|
||||
key: 'duration',
|
||||
value: 300,
|
||||
operator: PropertyOperator.LessThan,
|
||||
},
|
||||
],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithPropertyFilters = Template.bind({})
|
||||
WithPropertyFilters.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: 'browser',
|
||||
value: ['Chrome', 'Firefox'],
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Person,
|
||||
key: 'email',
|
||||
value: 'user@example.com',
|
||||
operator: PropertyOperator.IContains,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithOrFilters = Template.bind({})
|
||||
WithOrFilters.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.Or,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: 'browser',
|
||||
value: 'Chrome',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: 'browser',
|
||||
value: 'Safari',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithCohortFilter = Template.bind({})
|
||||
WithCohortFilter.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Cohort,
|
||||
key: 'id',
|
||||
value: 1,
|
||||
operator: PropertyOperator.In,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithTestAccountsExcluded = Template.bind({})
|
||||
WithTestAccountsExcluded.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
filter_test_accounts: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithOrderingAscending = Template.bind({})
|
||||
WithOrderingAscending.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
order: 'start_time',
|
||||
order_direction: 'ASC',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithOrderingDescending = Template.bind({})
|
||||
WithOrderingDescending.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
order: 'console_error_count',
|
||||
order_direction: 'DESC',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithActivityScoreOrdering = Template.bind({})
|
||||
WithActivityScoreOrdering.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
order: 'activity_score',
|
||||
order_direction: 'DESC',
|
||||
},
|
||||
}
|
||||
|
||||
export const ComplexFilters = Template.bind({})
|
||||
ComplexFilters.args = {
|
||||
filters: {
|
||||
date_from: '-30d',
|
||||
date_to: null,
|
||||
duration: [
|
||||
{
|
||||
type: PropertyFilterType.Recording,
|
||||
key: 'duration',
|
||||
value: 60,
|
||||
operator: PropertyOperator.GreaterThan,
|
||||
},
|
||||
],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: 'browser',
|
||||
value: ['Chrome', 'Firefox'],
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Person,
|
||||
key: 'email',
|
||||
value: '@company.com',
|
||||
operator: PropertyOperator.IContains,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: '$current_url',
|
||||
value: '/checkout',
|
||||
operator: PropertyOperator.IContains,
|
||||
},
|
||||
],
|
||||
},
|
||||
filter_test_accounts: true,
|
||||
order: 'console_error_count',
|
||||
order_direction: 'DESC',
|
||||
},
|
||||
}
|
||||
|
||||
export const NestedFilterGroups = Template.bind({})
|
||||
NestedFilterGroups.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [
|
||||
{
|
||||
type: FilterLogicalOperator.Or,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: 'browser',
|
||||
value: 'Chrome',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: 'browser',
|
||||
value: 'Safari',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Person,
|
||||
key: 'is_identified',
|
||||
value: true,
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const AllFiltersEnabled = Template.bind({})
|
||||
AllFiltersEnabled.args = {
|
||||
filters: {
|
||||
date_from: '-14d',
|
||||
date_to: '-1d',
|
||||
duration: [
|
||||
{
|
||||
type: PropertyFilterType.Recording,
|
||||
key: 'duration',
|
||||
value: 30,
|
||||
operator: PropertyOperator.GreaterThan,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Recording,
|
||||
key: 'duration',
|
||||
value: 600,
|
||||
operator: PropertyOperator.LessThan,
|
||||
},
|
||||
],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: 'browser',
|
||||
value: 'Chrome',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: '$os',
|
||||
value: ['Mac OS X', 'Windows'],
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
{
|
||||
type: FilterLogicalOperator.Or,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Person,
|
||||
key: 'email',
|
||||
value: '@gmail.com',
|
||||
operator: PropertyOperator.IContains,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Person,
|
||||
key: 'email',
|
||||
value: '@yahoo.com',
|
||||
operator: PropertyOperator.IContains,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
filter_test_accounts: true,
|
||||
order: 'activity_score',
|
||||
order_direction: 'DESC',
|
||||
},
|
||||
}
|
||||
|
||||
export const MinimalFilters = Template.bind({})
|
||||
MinimalFilters.args = {
|
||||
filters: {
|
||||
date_from: '-3d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const EmptyFilters = Template.bind({})
|
||||
EmptyFilters.args = {
|
||||
filters: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
|
||||
import { IconClock, IconFilter, IconSort } from '@posthog/icons'
|
||||
|
||||
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
|
||||
import { humanFriendlyDurationFilter } from 'scenes/session-recordings/filters/DurationFilter'
|
||||
|
||||
import { DurationType, RecordingUniversalFilters } from '~/types'
|
||||
|
||||
import { CompactUniversalFiltersDisplay } from './CompactUniversalFiltersDisplay'
|
||||
import { DateRangeSummary, InsightDetailSectionDisplay } from './InsightDetails'
|
||||
|
||||
function DurationSummary({ filters }: { filters: RecordingUniversalFilters }): JSX.Element | null {
|
||||
if (!filters.duration || filters.duration.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<InsightDetailSectionDisplay icon={<IconClock />} label="Duration">
|
||||
{filters.duration.map((durationFilter, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<span className="font-medium">
|
||||
{humanFriendlyDurationFilter(durationFilter, durationFilter.key as DurationType)}
|
||||
</span>
|
||||
{index < filters.duration.length - 1 && ' and '}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</InsightDetailSectionDisplay>
|
||||
)
|
||||
}
|
||||
|
||||
function FiltersSummary({ filters }: { filters: RecordingUniversalFilters }): JSX.Element | null {
|
||||
const hasFilters = filters.filter_group && filters.filter_group.values.length > 0
|
||||
|
||||
if (!hasFilters && !filters.filter_test_accounts) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<InsightDetailSectionDisplay icon={<IconFilter />} label="Filters">
|
||||
<CompactUniversalFiltersDisplay groupFilter={filters.filter_group} />
|
||||
{filters.filter_test_accounts && (
|
||||
<div>
|
||||
<LemonTag size="small">Test accounts excluded</LemonTag>
|
||||
</div>
|
||||
)}
|
||||
</InsightDetailSectionDisplay>
|
||||
)
|
||||
}
|
||||
|
||||
const ORDERABLE_FIELD_LABELS: Record<string, string> = {
|
||||
start_time: 'Start time',
|
||||
console_error_count: 'Console errors',
|
||||
click_count: 'Clicks',
|
||||
keypress_count: 'Key presses',
|
||||
mouse_activity_count: 'Mouse activity',
|
||||
activity_score: 'Activity score',
|
||||
recording_ttl: 'Recording TTL',
|
||||
}
|
||||
|
||||
function OrderingSummary({ filters }: { filters: RecordingUniversalFilters }): JSX.Element | null {
|
||||
if (!filters.order && !filters.order_direction) {
|
||||
return null
|
||||
}
|
||||
|
||||
const orderLabel = filters.order ? ORDERABLE_FIELD_LABELS[filters.order] || filters.order : 'Start time'
|
||||
const direction = filters.order_direction === 'ASC' ? 'ascending' : 'descending'
|
||||
|
||||
return (
|
||||
<InsightDetailSectionDisplay icon={<IconSort />} label="Sort order">
|
||||
<div className="font-medium">
|
||||
{orderLabel} ({direction})
|
||||
</div>
|
||||
</InsightDetailSectionDisplay>
|
||||
)
|
||||
}
|
||||
|
||||
export function RecordingsUniversalFiltersDisplay({ filters }: { filters: RecordingUniversalFilters }): JSX.Element {
|
||||
return (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
<DateRangeSummary dateFrom={filters.date_from} dateTo={filters.date_to} />
|
||||
<DurationSummary filters={filters} />
|
||||
<FiltersSummary filters={filters} />
|
||||
<OrderingSummary filters={filters} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface LettermarkProps {
|
||||
outlined?: boolean
|
||||
/** @default 'medium' */
|
||||
size?: 'xsmall' | 'small' | 'medium' | 'xlarge'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** An icon-sized lettermark.
|
||||
@@ -30,7 +31,7 @@ export interface LettermarkProps {
|
||||
* When given a string, the initial letter is shown. Numbers up to 99 are displayed in full, in integer form.
|
||||
*/
|
||||
export const Lettermark = React.forwardRef<HTMLDivElement, LettermarkProps>(function Lettermark(
|
||||
{ name, index, color, outlined = false, rounded = false, size = 'medium' },
|
||||
{ name, index, color, outlined = false, rounded = false, size = 'medium', className },
|
||||
ref
|
||||
) {
|
||||
const representation = name
|
||||
@@ -48,7 +49,8 @@ export const Lettermark = React.forwardRef<HTMLDivElement, LettermarkProps>(func
|
||||
colorIndex && `Lettermark--variant-${colorIndex}`,
|
||||
outlined && 'Lettermark--outlined',
|
||||
rounded && 'Lettermark--rounded',
|
||||
representation === '?' && 'Lettermark--unknown'
|
||||
representation === '?' && 'Lettermark--unknown',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
|
||||
@@ -318,9 +318,9 @@ export const featureFlagOperatorMap: Record<string, string> = {
|
||||
}
|
||||
|
||||
export const stickinessOperatorMap: Record<string, string> = {
|
||||
exact: '= Exactly',
|
||||
gte: '≥ At least',
|
||||
lte: '≤ At most (but at least once)',
|
||||
exact: '= exactly',
|
||||
gte: '≥ at least',
|
||||
lte: '≤ at most (but at least once)',
|
||||
}
|
||||
|
||||
export const cleanedPathOperatorMap: Record<string, string> = {
|
||||
|
||||
@@ -22,11 +22,13 @@ import { mswDecorator, useStorybookMocks } from '~/mocks/browser'
|
||||
import {
|
||||
AssistantMessage,
|
||||
AssistantMessageType,
|
||||
AssistantToolCallMessage,
|
||||
MultiVisualizationMessage,
|
||||
NotebookUpdateMessage,
|
||||
} from '~/queries/schema/schema-assistant-messages'
|
||||
import { FunnelsQuery, TrendsQuery } from '~/queries/schema/schema-general'
|
||||
import { InsightShortId } from '~/types'
|
||||
import { recordings } from '~/scenes/session-recordings/__mocks__/recordings'
|
||||
import { FilterLogicalOperator, InsightShortId, PropertyFilterType, PropertyOperator } from '~/types'
|
||||
|
||||
import { MaxInstance, MaxInstanceProps } from './Max'
|
||||
import conversationList from './__mocks__/conversationList.json'
|
||||
@@ -1309,6 +1311,245 @@ export const ThreadWithSQLQueryOverflow: StoryFn = () => {
|
||||
return <Template />
|
||||
}
|
||||
|
||||
export const SearchSessionRecordingsEmpty: StoryFn = () => {
|
||||
// This story demonstrates the search_session_recordings tool with nested filter groups
|
||||
// showcasing the fix for proper rendering of nested OR/AND groups
|
||||
const toolCallMessage: AssistantMessage = {
|
||||
type: AssistantMessageType.Assistant,
|
||||
content: 'Let me search for those recordings...',
|
||||
id: 'search-recordings-msg',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'search_tool_1',
|
||||
name: 'search_session_recordings',
|
||||
type: 'tool_call',
|
||||
args: {},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Tool call result with nested filter groups: (Chrome AND Mac) OR (Firefox AND Windows)
|
||||
const toolCallResult: AssistantToolCallMessage = {
|
||||
type: AssistantMessageType.ToolCall,
|
||||
tool_call_id: 'search_tool_1',
|
||||
content: 'Found recordings matching your criteria',
|
||||
id: 'tool-result-1',
|
||||
ui_payload: {
|
||||
search_session_recordings: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.Or,
|
||||
values: [
|
||||
{
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: 'browser',
|
||||
value: 'Chrome',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: '$os',
|
||||
value: 'Mac OS X',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: 'browser',
|
||||
value: 'Firefox',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: '$os',
|
||||
value: 'Windows',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
useStorybookMocks({
|
||||
post: {
|
||||
'/api/environments/:team_id/conversations/': (_, res, ctx) =>
|
||||
res(
|
||||
ctx.text(
|
||||
generateChunk([
|
||||
'event: conversation',
|
||||
`data: ${JSON.stringify({ id: CONVERSATION_ID })}`,
|
||||
'event: message',
|
||||
`data: ${JSON.stringify({
|
||||
...humanMessage,
|
||||
content:
|
||||
'Show me recordings where users are on Chrome with Mac OR Firefox with Windows',
|
||||
})}`,
|
||||
'event: message',
|
||||
`data: ${JSON.stringify(toolCallMessage)}`,
|
||||
'event: message',
|
||||
`data: ${JSON.stringify(toolCallResult)}`,
|
||||
])
|
||||
)
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
const { setConversationId } = useActions(maxLogic({ tabId: 'storybook' }))
|
||||
const threadLogic = maxThreadLogic({ conversationId: CONVERSATION_ID, conversation: null, tabId: 'storybook' })
|
||||
const { askMax } = useActions(threadLogic)
|
||||
const { dataProcessingAccepted } = useValues(maxGlobalLogic)
|
||||
|
||||
useEffect(() => {
|
||||
if (dataProcessingAccepted) {
|
||||
setTimeout(() => {
|
||||
setConversationId(CONVERSATION_ID)
|
||||
askMax('Show me recordings where users are on Chrome with Mac OR Firefox with Windows')
|
||||
}, 0)
|
||||
}
|
||||
}, [dataProcessingAccepted, setConversationId, askMax])
|
||||
|
||||
if (!dataProcessingAccepted) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <Template />
|
||||
}
|
||||
SearchSessionRecordingsEmpty.parameters = {
|
||||
testOptions: {
|
||||
waitForLoadersToDisappear: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const SearchSessionRecordingsWithResults: StoryFn = () => {
|
||||
const toolCallMessage: AssistantMessage = {
|
||||
type: AssistantMessageType.Assistant,
|
||||
content: 'Let me search for those recordings...',
|
||||
id: 'search-recordings-with-results-msg',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'search_tool_1',
|
||||
name: 'search_session_recordings',
|
||||
type: 'tool_call',
|
||||
args: {},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Tool call result with filter for Microsoft Edge on Linux
|
||||
const toolCallResult: AssistantToolCallMessage = {
|
||||
type: AssistantMessageType.ToolCall,
|
||||
tool_call_id: 'search_tool_1',
|
||||
content: 'Found 2 recordings matching your criteria',
|
||||
id: 'tool-result-with-recordings-1',
|
||||
ui_payload: {
|
||||
search_session_recordings: {
|
||||
date_from: '-7d',
|
||||
date_to: null,
|
||||
duration: [],
|
||||
filter_group: {
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [
|
||||
{
|
||||
type: FilterLogicalOperator.And,
|
||||
values: [
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: '$browser',
|
||||
value: 'Microsoft Edge',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
{
|
||||
type: PropertyFilterType.Event,
|
||||
key: '$os',
|
||||
value: 'Linux',
|
||||
operator: PropertyOperator.Exact,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
useStorybookMocks({
|
||||
post: {
|
||||
'/api/environments/:team_id/conversations/': (_, res, ctx) =>
|
||||
res(
|
||||
ctx.text(
|
||||
generateChunk([
|
||||
'event: conversation',
|
||||
`data: ${JSON.stringify({ id: CONVERSATION_ID })}`,
|
||||
'event: message',
|
||||
`data: ${JSON.stringify({
|
||||
...humanMessage,
|
||||
content: 'Show me recordings where users are on Microsoft Edge with Linux',
|
||||
})}`,
|
||||
'event: message',
|
||||
`data: ${JSON.stringify(toolCallMessage)}`,
|
||||
'event: message',
|
||||
`data: ${JSON.stringify(toolCallResult)}`,
|
||||
])
|
||||
)
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
const { setConversationId } = useActions(maxLogic({ tabId: 'storybook' }))
|
||||
const threadLogic = maxThreadLogic({ conversationId: CONVERSATION_ID, conversation: null, tabId: 'storybook' })
|
||||
const { askMax } = useActions(threadLogic)
|
||||
const { dataProcessingAccepted } = useValues(maxGlobalLogic)
|
||||
|
||||
useEffect(() => {
|
||||
if (dataProcessingAccepted) {
|
||||
setTimeout(() => {
|
||||
setConversationId(CONVERSATION_ID)
|
||||
askMax('Show me recordings where users are on Microsoft Edge with Linux')
|
||||
}, 0)
|
||||
}
|
||||
}, [dataProcessingAccepted, setConversationId, askMax])
|
||||
|
||||
if (!dataProcessingAccepted) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <Template />
|
||||
}
|
||||
SearchSessionRecordingsWithResults.decorators = [
|
||||
mswDecorator({
|
||||
get: {
|
||||
'/api/environments/:team_id/session_recordings': (req) => {
|
||||
const version = req.url.searchParams.get('version')
|
||||
return [
|
||||
200,
|
||||
{
|
||||
has_next: false,
|
||||
results: recordings,
|
||||
version,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
SearchSessionRecordingsWithResults.parameters = {
|
||||
testOptions: {
|
||||
waitForLoadersToDisappear: false,
|
||||
},
|
||||
}
|
||||
|
||||
function generateChunk(events: string[]): string {
|
||||
return events.map((event) => (event.startsWith('event:') ? `${event}\n` : `${event}\n\n`)).join('')
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ import { ToolRegistration, getToolDefinition } from './max-constants'
|
||||
import { maxGlobalLogic } from './maxGlobalLogic'
|
||||
import { MessageStatus, ThreadMessage, maxLogic } from './maxLogic'
|
||||
import { maxThreadLogic } from './maxThreadLogic'
|
||||
import { MessageTemplate } from './messages/MessageTemplate'
|
||||
import { UIPayloadAnswer } from './messages/UIPayloadAnswer'
|
||||
import { MAX_SLASH_COMMANDS } from './slash-commands'
|
||||
import {
|
||||
castAssistantQuery,
|
||||
@@ -302,6 +304,20 @@ function Message({ message, isLastInGroup, isFinal }: MessageProps): JSX.Element
|
||||
{actionsElement}
|
||||
</div>
|
||||
)
|
||||
} else if (
|
||||
isAssistantToolCallMessage(message) &&
|
||||
message.ui_payload &&
|
||||
Object.keys(message.ui_payload).length > 0
|
||||
) {
|
||||
const [toolName, toolPayload] = Object.entries(message.ui_payload)[0]
|
||||
return (
|
||||
<UIPayloadAnswer
|
||||
key={key}
|
||||
toolCallId={message.tool_call_id}
|
||||
toolName={toolName}
|
||||
toolPayload={toolPayload}
|
||||
/>
|
||||
)
|
||||
} else if (isAssistantToolCallMessage(message) || isFailureMessage(message)) {
|
||||
return (
|
||||
<TextAnswer
|
||||
@@ -360,48 +376,6 @@ function MessageGroupSkeleton({
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageTemplateProps {
|
||||
type: 'human' | 'ai'
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
boxClassName?: string
|
||||
wrapperClassName?: string
|
||||
children?: React.ReactNode
|
||||
header?: React.ReactNode
|
||||
}
|
||||
|
||||
const MessageTemplate = React.forwardRef<HTMLDivElement, MessageTemplateProps>(function MessageTemplate(
|
||||
{ type, children, className, boxClassName, wrapperClassName, action, header },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'flex flex-col gap-px w-full break-words scroll-mt-12',
|
||||
type === 'human' ? 'items-end' : 'items-start',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={twMerge('max-w-full', wrapperClassName)}>
|
||||
{header}
|
||||
{children && (
|
||||
<div
|
||||
className={twMerge(
|
||||
'border py-2 px-3 rounded-lg bg-surface-primary',
|
||||
type === 'human' && 'font-medium',
|
||||
boxClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface TextAnswerProps {
|
||||
message: (AssistantMessage | FailureMessage | AssistantToolCallMessage) & ThreadMessage
|
||||
interactable?: boolean
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
AssistantGenerationStatusType,
|
||||
AssistantMessage,
|
||||
AssistantMessageType,
|
||||
AssistantTool,
|
||||
AssistantUpdateEvent,
|
||||
FailureMessage,
|
||||
HumanMessage,
|
||||
@@ -47,6 +48,7 @@ import { maxBillingContextLogic } from './maxBillingContextLogic'
|
||||
import { maxGlobalLogic } from './maxGlobalLogic'
|
||||
import { maxLogic } from './maxLogic'
|
||||
import type { maxThreadLogicType } from './maxThreadLogicType'
|
||||
import { RENDERABLE_UI_PAYLOAD_TOOLS } from './messages/UIPayloadAnswer'
|
||||
import { MAX_SLASH_COMMANDS, SlashCommand } from './slash-commands'
|
||||
import { isAssistantMessage, isAssistantToolCallMessage, isHumanMessage, isNotebookUpdateMessage } from './utils'
|
||||
import { getRandomThinkingMessage } from './utils/thinkingMessages'
|
||||
@@ -516,12 +518,15 @@ export const maxThreadLogic = kea<maxThreadLogicType>([
|
||||
|
||||
for (let i = 0; i < thread.length; i++) {
|
||||
const currentMessage: ThreadMessage = thread[i]
|
||||
|
||||
// Skip AssistantToolCallMessage - they're now merged into AssistantMessage tool_calls
|
||||
if (currentMessage.type === AssistantMessageType.ToolCall) {
|
||||
// Skip AssistantToolCallMessage that don't have a renderable UI payload
|
||||
if (
|
||||
currentMessage.type === AssistantMessageType.ToolCall &&
|
||||
!Object.keys(currentMessage.ui_payload || {}).some((toolName) =>
|
||||
RENDERABLE_UI_PAYLOAD_TOOLS.includes(toolName as AssistantTool)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty assistant messages with no content, tool calls, or thinking
|
||||
if (
|
||||
currentMessage.type === AssistantMessageType.Assistant &&
|
||||
@@ -533,7 +538,6 @@ export const maxThreadLogic = kea<maxThreadLogicType>([
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
processedThread.push(currentMessage)
|
||||
}
|
||||
|
||||
|
||||
44
frontend/src/scenes/max/messages/MessageTemplate.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface MessageTemplateProps {
|
||||
type: 'human' | 'ai'
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
boxClassName?: string
|
||||
wrapperClassName?: string
|
||||
children?: React.ReactNode
|
||||
header?: React.ReactNode
|
||||
}
|
||||
|
||||
export const MessageTemplate = React.forwardRef<HTMLDivElement, MessageTemplateProps>(function MessageTemplate(
|
||||
{ type, children, className, boxClassName, wrapperClassName, action, header },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'flex flex-col gap-px w-full break-words scroll-mt-12',
|
||||
type === 'human' ? 'items-end' : 'items-start',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={twMerge('max-w-full', wrapperClassName)}>
|
||||
{header}
|
||||
{children && (
|
||||
<div
|
||||
className={twMerge(
|
||||
'border py-2 px-3 rounded-lg bg-surface-primary',
|
||||
type === 'human' && 'font-medium',
|
||||
boxClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { RecordingsUniversalFiltersDisplay } from 'lib/components/Cards/InsightCard/RecordingsUniversalFiltersDisplay'
|
||||
|
||||
import { RecordingUniversalFilters } from '~/types'
|
||||
|
||||
export function RecordingsFiltersSummary({ filters }: { filters: RecordingUniversalFilters }): JSX.Element {
|
||||
return <RecordingsUniversalFiltersDisplay filters={filters} />
|
||||
}
|
||||
103
frontend/src/scenes/max/messages/UIPayloadAnswer.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { BindLogic, useActions, useValues } from 'kea'
|
||||
|
||||
import { LemonButton, Spinner } from '@posthog/lemon-ui'
|
||||
|
||||
import { EmptyMessage } from 'lib/components/EmptyMessage/EmptyMessage'
|
||||
import { SessionRecordingPreview } from 'scenes/session-recordings/playlist/SessionRecordingPreview'
|
||||
import {
|
||||
SessionRecordingPlaylistLogicProps,
|
||||
sessionRecordingsPlaylistLogic,
|
||||
} from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic'
|
||||
|
||||
import { AssistantTool } from '~/queries/schema/schema-assistant-messages'
|
||||
import { RecordingUniversalFilters } from '~/types'
|
||||
|
||||
import { MessageTemplate } from './MessageTemplate'
|
||||
import { RecordingsFiltersSummary } from './RecordingsFiltersSummary'
|
||||
|
||||
export const RENDERABLE_UI_PAYLOAD_TOOLS: AssistantTool[] = ['search_session_recordings']
|
||||
|
||||
export function UIPayloadAnswer({
|
||||
toolCallId,
|
||||
toolName,
|
||||
toolPayload,
|
||||
}: {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
toolPayload: any
|
||||
}): JSX.Element | null {
|
||||
if (toolName === 'search_session_recordings') {
|
||||
const filters = toolPayload as RecordingUniversalFilters
|
||||
return <RecordingsWidget toolCallId={toolCallId} filters={filters} />
|
||||
}
|
||||
// It's not expected to hit the null branch below, because such a case SHOULD have already been filtered out
|
||||
// in maxThreadLogic.selectors.threadGrouped, but better safe than sorry - there can be deployments mismatches etc.
|
||||
return null
|
||||
}
|
||||
|
||||
function RecordingsWidget({
|
||||
toolCallId,
|
||||
filters,
|
||||
}: {
|
||||
toolCallId: string
|
||||
filters: RecordingUniversalFilters
|
||||
}): JSX.Element {
|
||||
const logicProps: SessionRecordingPlaylistLogicProps = {
|
||||
logicKey: `ai-recordings-widget-${toolCallId}`,
|
||||
filters,
|
||||
updateSearchParams: false,
|
||||
autoPlay: false,
|
||||
}
|
||||
|
||||
return (
|
||||
<BindLogic logic={sessionRecordingsPlaylistLogic} props={logicProps}>
|
||||
<MessageTemplate type="ai" wrapperClassName="w-full" boxClassName="p-0 overflow-hidden">
|
||||
<RecordingsFiltersSummary filters={filters} />
|
||||
<RecordingsListContent />
|
||||
</MessageTemplate>
|
||||
</BindLogic>
|
||||
)
|
||||
}
|
||||
|
||||
function RecordingsListContent(): JSX.Element {
|
||||
const { otherRecordings, sessionRecordingsResponseLoading, hasNext } = useValues(sessionRecordingsPlaylistLogic)
|
||||
const { maybeLoadSessionRecordings } = useActions(sessionRecordingsPlaylistLogic)
|
||||
|
||||
const hasRecordings = otherRecordings.length > 0
|
||||
|
||||
return (
|
||||
<div className="*:border-t max-h-80 overflow-y-auto">
|
||||
{sessionRecordingsResponseLoading && !hasRecordings ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-muted">
|
||||
<Spinner textColored />
|
||||
<span>Loading recordings...</span>
|
||||
</div>
|
||||
) : !hasRecordings ? (
|
||||
<div className="py-2">
|
||||
<EmptyMessage title="No recordings found" description="No recordings match the specified filters" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{otherRecordings.map((recording) => (
|
||||
<div key={recording.id}>
|
||||
<SessionRecordingPreview recording={recording} selectable={false} />
|
||||
</div>
|
||||
))}
|
||||
{hasNext && (
|
||||
<div className="p-3">
|
||||
<LemonButton
|
||||
fullWidth
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={() => maybeLoadSessionRecordings('older')}
|
||||
loading={sessionRecordingsResponseLoading}
|
||||
>
|
||||
Load more recordings
|
||||
</LemonButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||