Merge branch 'master' into feat/alerts-for-trends-with-breakdowns
@ -17,10 +17,8 @@ describe('Feature Flags', () => {
|
||||
})
|
||||
|
||||
it('Display product introduction when no feature flags exist', () => {
|
||||
// ensure unique names to avoid clashes
|
||||
cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags')
|
||||
cy.get('[data-attr=new-feature-flag]').click()
|
||||
cy.contains('Create your first feature flag').should('exist')
|
||||
cy.contains('Welcome to Feature flags!').should('exist')
|
||||
})
|
||||
|
||||
it('Create feature flag', () => {
|
||||
@ -104,6 +102,7 @@ describe('Feature Flags', () => {
|
||||
cy.get('[data-attr=feature-flag-key]').focus().type(name).should('have.value', name)
|
||||
cy.get('[data-attr=rollout-percentage]').type('{selectall}50').should('have.value', '50')
|
||||
cy.get('[data-attr=save-feature-flag]').first().click()
|
||||
cy.get('[data-attr=toast-close-button]').click()
|
||||
|
||||
// after save there should be a delete button
|
||||
cy.get('[data-attr="more-button"]').click()
|
||||
@ -308,7 +307,8 @@ describe('Feature Flags', () => {
|
||||
cy.get('.operator-value-option').contains('> after').should('not.exist')
|
||||
})
|
||||
|
||||
it('Allow setting multivariant rollout percentage to zero', () => {
|
||||
it('Allows setting multivariant rollout percentage to zero', () => {
|
||||
cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags')
|
||||
// Start creating a multivariant flag
|
||||
cy.get('[data-attr=new-feature-flag]').click()
|
||||
cy.get('[data-attr=feature-flag-served-value-segmented-button]')
|
||||
@ -328,6 +328,18 @@ describe('Feature Flags', () => {
|
||||
cy.get('[data-attr=feature-flag-variant-rollout-percentage-input]').click().type(`4.5`).should('have.value', 4)
|
||||
})
|
||||
|
||||
it('Sets URL properly when switching between tabs', () => {
|
||||
cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags')
|
||||
cy.get('[data-attr=feature-flags-tab-navigation]').contains('History').click()
|
||||
cy.url().should('include', `tab=history`)
|
||||
|
||||
cy.get('[data-attr=feature-flags-tab-navigation]').contains('Overview').click()
|
||||
cy.url().should('include', `tab=overview`)
|
||||
|
||||
cy.get('[data-attr=feature-flags-tab-navigation]').contains('History').click()
|
||||
cy.url().should('include', `tab=history`)
|
||||
})
|
||||
|
||||
it('Renders flags in FlagSelector', () => {
|
||||
// Create flag name
|
||||
cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags')
|
||||
|
@ -269,6 +269,7 @@ describe('Surveys', () => {
|
||||
|
||||
// Set responses limit
|
||||
cy.get('.LemonCollapsePanel').contains('Completion conditions').click()
|
||||
cy.get('[data-attr=survey-collection-until-limit]').first().click()
|
||||
cy.get('[data-attr=survey-responses-limit-input]').focus().type('228').click()
|
||||
|
||||
// Save the survey
|
||||
@ -276,7 +277,7 @@ describe('Surveys', () => {
|
||||
cy.get('button[data-attr="launch-survey"]').should('have.text', 'Launch')
|
||||
|
||||
cy.reload()
|
||||
cy.contains('The survey will be stopped once 228 responses are received.').should('be.visible')
|
||||
cy.contains('The survey will be stopped once 100228 responses are received.').should('be.visible')
|
||||
})
|
||||
|
||||
it('creates a new survey with branching logic', () => {
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 57 KiB |
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 112 KiB |
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 114 KiB |
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 709 KiB |
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 706 KiB |
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 743 KiB |
Before Width: | Height: | Size: 756 KiB After Width: | Height: | Size: 740 KiB |
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 709 KiB |
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 706 KiB |
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 709 KiB |
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 706 KiB |
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 709 KiB |
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 706 KiB |
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 709 KiB |
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 706 KiB |
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 709 KiB |
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 706 KiB |
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 709 KiB |
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 706 KiB |
@ -175,7 +175,7 @@
|
||||
.Sidebar3000 {
|
||||
--sidebar-slider-padding: 0.125rem;
|
||||
--sidebar-horizontal-padding: 0.5rem;
|
||||
--sidebar-row-height: 2rem;
|
||||
--sidebar-row-height: 2.5rem;
|
||||
--sidebar-background: var(--bg-3000);
|
||||
|
||||
position: relative;
|
||||
@ -451,7 +451,8 @@
|
||||
}
|
||||
|
||||
// Accommodate menu button by moving stuff out of the way
|
||||
&.SidebarListItem--has-menu:not(.SidebarListItem--extended) .SidebarListItem__link {
|
||||
&.SidebarListItem--has-menu:not(.SidebarListItem--extended) .SidebarListItem__link,
|
||||
&.SidebarListItem--has-menu:not(.SidebarListItem--extended) .SidebarListItem__button {
|
||||
padding-right: calc(var(--sidebar-horizontal-padding) + 1.25rem);
|
||||
}
|
||||
|
||||
@ -523,6 +524,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.SidebarListItem__button,
|
||||
.SidebarListItem__link,
|
||||
.SidebarListItem__rename {
|
||||
--sidebar-list-item-inset: calc(
|
||||
@ -555,6 +557,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.SidebarListItem__button {
|
||||
row-gap: 1px;
|
||||
padding: 0 var(--sidebar-horizontal-padding) 0 var(--sidebar-list-item-inset);
|
||||
color: inherit !important; // Disable link color
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-3000);
|
||||
}
|
||||
}
|
||||
|
||||
.SidebarListItem__rename {
|
||||
// Pseudo-elements don't work on inputs, so we use a wrapper div
|
||||
background: var(--bg-light);
|
||||
|
@ -27,7 +27,7 @@ export function Navbar(): JSX.Element {
|
||||
const { isAccountPopoverOpen, systemStatusHealthy } = useValues(navigationLogic)
|
||||
const { closeAccountPopover, toggleAccountPopover } = useActions(navigationLogic)
|
||||
const { isNavShown, isSidebarShown, activeNavbarItemId, navbarItems, mobileLayout } = useValues(navigation3000Logic)
|
||||
const { showSidebar, hideSidebar, toggleNavCollapsed, hideNavOnMobile } = useActions(navigation3000Logic)
|
||||
const { toggleNavCollapsed, hideNavOnMobile, showSidebar, hideSidebar } = useActions(navigation3000Logic)
|
||||
const { featureFlags } = useValues(featureFlagLogic)
|
||||
const { toggleSearchBar } = useActions(commandBarLogic)
|
||||
|
||||
|
@ -19,8 +19,16 @@ const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
interface SidebarProps {
|
||||
navbarItem: SidebarNavbarItem // Sidebar can only be rendered if there's an active sidebar navbar item
|
||||
sidebarOverlay?: React.ReactNode
|
||||
sidebarOverlayProps?: SidebarOverlayProps
|
||||
}
|
||||
export function Sidebar({ navbarItem }: SidebarProps): JSX.Element {
|
||||
|
||||
interface SidebarOverlayProps {
|
||||
className?: string
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
export function Sidebar({ navbarItem, sidebarOverlay, sidebarOverlayProps }: SidebarProps): JSX.Element {
|
||||
const inputElementRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const {
|
||||
@ -81,6 +89,11 @@ export function Sidebar({ navbarItem }: SidebarProps): JSX.Element {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{sidebarOverlay && (
|
||||
<SidebarOverlay {...sidebarOverlayProps} isOpen={isShown && sidebarOverlayProps?.isOpen} width={width}>
|
||||
{sidebarOverlay}
|
||||
</SidebarOverlay>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -199,3 +212,24 @@ function SidebarKeyboardShortcut(): JSX.Element {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarOverlay({
|
||||
className,
|
||||
isOpen = false,
|
||||
children,
|
||||
width,
|
||||
}: SidebarOverlayProps & { children: React.ReactNode; width: number }): JSX.Element | null {
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('absolute top-0 left-0 h-full bg-bg-3000', className)}
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -13,7 +13,14 @@ import { InfiniteLoader } from 'react-virtualized/dist/es/InfiniteLoader'
|
||||
import { List, ListProps } from 'react-virtualized/dist/es/List'
|
||||
|
||||
import { ITEM_KEY_PART_SEPARATOR, navigation3000Logic } from '../navigationLogic'
|
||||
import { BasicListItem, ExtendedListItem, ExtraListItemContext, SidebarCategory, TentativeListItem } from '../types'
|
||||
import {
|
||||
BasicListItem,
|
||||
ButtonListItem,
|
||||
ExtendedListItem,
|
||||
ExtraListItemContext,
|
||||
SidebarCategory,
|
||||
TentativeListItem,
|
||||
} from '../types'
|
||||
import { KeyboardShortcut } from './KeyboardShortcut'
|
||||
|
||||
export function SidebarList({ category }: { category: SidebarCategory }): JSX.Element {
|
||||
@ -122,7 +129,7 @@ export function SidebarList({ category }: { category: SidebarCategory }): JSX.El
|
||||
}
|
||||
|
||||
interface SidebarListItemProps {
|
||||
item: BasicListItem | ExtendedListItem | TentativeListItem
|
||||
item: BasicListItem | ExtendedListItem | TentativeListItem | ButtonListItem
|
||||
validateName?: SidebarCategory['validateName']
|
||||
active?: boolean
|
||||
style: React.CSSProperties
|
||||
@ -132,6 +139,10 @@ function isItemTentative(item: SidebarListItemProps['item']): item is TentativeL
|
||||
return 'onSave' in item
|
||||
}
|
||||
|
||||
function isItemClickable(item: SidebarListItemProps['item']): item is ButtonListItem {
|
||||
return 'onClick' in item
|
||||
}
|
||||
|
||||
function SidebarListItem({ item, validateName, active, style }: SidebarListItemProps): JSX.Element {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [newName, setNewName] = useState<null | string>(null)
|
||||
@ -218,7 +229,13 @@ function SidebarListItem({ item, validateName, active, style }: SidebarListItemP
|
||||
}) // Intentionally run on every render so that ref value changes are picked up
|
||||
|
||||
let content: JSX.Element
|
||||
if (!save || (!isItemTentative(item) && newName === null)) {
|
||||
if (isItemClickable(item)) {
|
||||
content = (
|
||||
<li className="SidebarListItem__button" onClick={item.onClick}>
|
||||
<h5>{item.name}</h5>
|
||||
</li>
|
||||
)
|
||||
} else if (!save || (!isItemTentative(item) && newName === null)) {
|
||||
if (isItemTentative(item)) {
|
||||
throw new Error('Tentative items should not be rendered in read mode')
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ import { LemonMenuOverlay } from 'lib/lemon-ui/LemonMenu/LemonMenu'
|
||||
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
|
||||
import { isNotNil } from 'lib/utils'
|
||||
import React from 'react'
|
||||
import { editorSidebarLogic } from 'scenes/data-warehouse/editor/editorSidebarLogic'
|
||||
import { sceneLogic } from 'scenes/sceneLogic'
|
||||
import { Scene } from 'scenes/sceneTypes'
|
||||
import { teamLogic } from 'scenes/teamLogic'
|
||||
@ -103,9 +104,6 @@ export const navigation3000Logic = kea<navigation3000LogicType>([
|
||||
reducers({
|
||||
isSidebarShown: [
|
||||
true,
|
||||
{
|
||||
persist: true,
|
||||
},
|
||||
{
|
||||
hideSidebar: () => false,
|
||||
showSidebar: () => true,
|
||||
@ -514,9 +512,10 @@ export const navigation3000Logic = kea<navigation3000LogicType>([
|
||||
featureFlags[FEATURE_FLAGS.SQL_EDITOR]
|
||||
? {
|
||||
identifier: Scene.SQLEditor,
|
||||
label: 'SQL editor',
|
||||
label: 'Data warehouse',
|
||||
icon: <IconServer />,
|
||||
to: isUsingSidebar ? undefined : urls.sqlEditor(),
|
||||
to: urls.sqlEditor(),
|
||||
logic: editorSidebarLogic,
|
||||
}
|
||||
: null,
|
||||
featureFlags[FEATURE_FLAGS.DATA_MODELING] && hasOnboardedAnyProduct
|
||||
@ -598,6 +597,9 @@ export const navigation3000Logic = kea<navigation3000LogicType>([
|
||||
activeNavbarItemId: [
|
||||
(s) => [s.activeNavbarItemIdRaw, featureFlagLogic.selectors.featureFlags],
|
||||
(activeNavbarItemIdRaw, featureFlags): string | null => {
|
||||
if (featureFlags[FEATURE_FLAGS.SQL_EDITOR] && activeNavbarItemIdRaw === Scene.SQLEditor) {
|
||||
return Scene.SQLEditor
|
||||
}
|
||||
if (!featureFlags[FEATURE_FLAGS.POSTHOG_3000_NAV]) {
|
||||
return null
|
||||
}
|
||||
|
@ -104,6 +104,7 @@ export interface BasicListItem {
|
||||
* URL within the app. In specific cases this can be null - such items are italicized.
|
||||
*/
|
||||
url: string | null
|
||||
onClick?: () => void
|
||||
/** An optional marker to highlight item state. */
|
||||
marker?: {
|
||||
/** A marker of type `fold` is a small triangle in the top left, `ribbon` is a narrow ribbon to the left. */
|
||||
@ -146,3 +147,8 @@ export interface TentativeListItem {
|
||||
adding: boolean
|
||||
ref?: BasicListItem['ref']
|
||||
}
|
||||
|
||||
export interface ButtonListItem extends BasicListItem {
|
||||
key: '__button__'
|
||||
onClick: () => void
|
||||
}
|
||||
|
@ -2206,7 +2206,7 @@ const api = {
|
||||
},
|
||||
async update(
|
||||
viewId: DataWarehouseSavedQuery['id'],
|
||||
data: Pick<DataWarehouseSavedQuery, 'name' | 'query'>
|
||||
data: Partial<DataWarehouseSavedQuery>
|
||||
): Promise<DataWarehouseSavedQuery> {
|
||||
return await new ApiRequest().dataWarehouseSavedQuery(viewId).update({ data })
|
||||
},
|
||||
|
@ -959,7 +959,7 @@ export const HedgehogBuddy = React.forwardRef<HTMLDivElement, HedgehogBuddyProps
|
||||
|
||||
useEffect(() => {
|
||||
onPositionChange?.(actor)
|
||||
}, [actor.x, actor.y])
|
||||
}, [actor.x, actor.y, actor.direction])
|
||||
|
||||
const onClick = (): void => {
|
||||
!actor.isDragging && _onClick?.(actor)
|
||||
|
@ -10,7 +10,7 @@ function isCohortCriteriaGroupFilter(
|
||||
|
||||
const hasBehavioralFilter = (cohort: CohortType, allCohorts: CohortType[]): boolean => {
|
||||
const checkCriteriaGroup = (group: CohortCriteriaGroupFilter): boolean => {
|
||||
return group.values.some((value) => {
|
||||
return group.values?.some((value) => {
|
||||
if (isCohortCriteriaGroupFilter(value)) {
|
||||
return checkCriteriaGroup(value)
|
||||
}
|
||||
|
@ -169,6 +169,7 @@ export const FEATURE_FLAGS = {
|
||||
SURVEYS_EVENTS: 'surveys-events', // owner: #team-feature-success
|
||||
SURVEYS_ACTIONS: 'surveys-actions', // owner: #team-feature-success
|
||||
SURVEYS_RECURRING: 'surveys-recurring', // owner: #team-feature-success
|
||||
SURVEYS_ADAPTIVE_COLLECTION: 'surveys-recurring', // owner: #team-feature-success
|
||||
YEAR_IN_HOG: 'year-in-hog', // owner: #team-replay
|
||||
SESSION_REPLAY_EXPORT_MOBILE_DATA: 'session-replay-export-mobile-data', // owner: #team-replay
|
||||
DISCUSSIONS: 'discussions', // owner: #team-replay
|
||||
@ -229,6 +230,8 @@ export const FEATURE_FLAGS = {
|
||||
EDIT_DWH_SOURCE_CONFIG: 'edit_dwh_source_config', // owner: @Gilbert09 #team-data-warehouse
|
||||
AI_SURVEY_RESPONSE_SUMMARY: 'ai-survey-response-summary', // owner: @pauldambra
|
||||
CUSTOM_CHANNEL_TYPE_RULES: 'custom-channel-type-rules', // owner: @robbie-c #team-web-analytics
|
||||
SELF_SERVE_CREDIT_OVERRIDE: 'self-serve-credit-override', // owner: @zach
|
||||
EXPERIMENTS_MIGRATION_DISABLE_UI: 'experiments-migration-disable-ui', // owner: @jurajmajerik #team-experiments
|
||||
} as const
|
||||
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
|
||||
|
||||
|
@ -12,6 +12,7 @@ export type LemonFormDialogProps = LemonDialogFormPropsType &
|
||||
Omit<LemonDialogProps, 'primaryButton' | 'secondaryButton' | 'tertiaryButton'> & {
|
||||
initialValues: Record<string, any>
|
||||
onSubmit: (values: Record<string, any>) => void | Promise<void>
|
||||
shouldAwaitSubmit?: boolean
|
||||
}
|
||||
|
||||
export type LemonDialogProps = Pick<
|
||||
@ -26,6 +27,7 @@ export type LemonDialogProps = Pick<
|
||||
onClose?: () => void
|
||||
onAfterClose?: () => void
|
||||
closeOnNavigate?: boolean
|
||||
shouldAwaitSubmit?: boolean
|
||||
}
|
||||
|
||||
export function LemonDialog({
|
||||
@ -37,12 +39,14 @@ export function LemonDialog({
|
||||
content,
|
||||
initialFormValues,
|
||||
closeOnNavigate = true,
|
||||
shouldAwaitSubmit = false,
|
||||
footer,
|
||||
...props
|
||||
}: LemonDialogProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const { currentLocation } = useValues(router)
|
||||
const lastLocation = useRef(currentLocation.pathname)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
primaryButton =
|
||||
primaryButton ||
|
||||
@ -63,8 +67,20 @@ export function LemonDialog({
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
{...button}
|
||||
onClick={(e) => {
|
||||
button.onClick?.(e)
|
||||
loading={button === primaryButton && shouldAwaitSubmit ? isLoading : undefined}
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick={async (e) => {
|
||||
if (button === primaryButton && shouldAwaitSubmit) {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await button.onClick?.(e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
} else {
|
||||
button.onClick?.(e)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}}
|
||||
/>
|
||||
@ -117,7 +133,8 @@ export const LemonFormDialog = ({
|
||||
type: 'primary',
|
||||
children: 'Submit',
|
||||
htmlType: 'submit',
|
||||
onClick: () => void onSubmit(form),
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick: props.shouldAwaitSubmit ? async () => await onSubmit(form) : () => void onSubmit(form),
|
||||
disabledReason: !isFormValid ? firstError : undefined,
|
||||
}
|
||||
|
||||
|
@ -199,7 +199,7 @@ export const Popover = React.forwardRef<HTMLDivElement, PopoverProps>(function P
|
||||
if (visible && referenceRef?.current && floatingElement) {
|
||||
return autoUpdate(referenceRef.current, floatingElement, update)
|
||||
}
|
||||
}, [visible, referenceRef?.current, floatingElement, ...additionalRefs])
|
||||
}, [visible, placement, referenceRef?.current, floatingElement, ...additionalRefs])
|
||||
|
||||
const floatingContainer = useFloatingContainer()
|
||||
|
||||
|
@ -0,0 +1,318 @@
|
||||
import { expectLogic } from 'kea-test-utils'
|
||||
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
|
||||
|
||||
import { DataVisualizationNode, NodeKind } from '~/queries/schema'
|
||||
import { initKeaTests } from '~/test/init'
|
||||
import { ChartDisplayType } from '~/types'
|
||||
|
||||
import { dataNodeLogic } from '../../DataNode/dataNodeLogic'
|
||||
import { dataVisualizationLogic, DataVisualizationLogicProps } from '../dataVisualizationLogic'
|
||||
import { seriesBreakdownLogic } from './seriesBreakdownLogic'
|
||||
|
||||
const testUniqueKey = 'testUniqueKey'
|
||||
|
||||
const initialQuery: DataVisualizationNode = {
|
||||
kind: NodeKind.DataVisualizationNode,
|
||||
source: {
|
||||
kind: NodeKind.HogQLQuery,
|
||||
query: `select event, properties.$browser as browser, count() as total_count from events group by 1, 2`,
|
||||
},
|
||||
tableSettings: {
|
||||
columns: [
|
||||
{
|
||||
column: 'event',
|
||||
settings: {
|
||||
formatting: {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
column: 'browser',
|
||||
settings: {
|
||||
formatting: {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
column: 'total_count',
|
||||
settings: {
|
||||
formatting: {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
conditionalFormatting: [],
|
||||
},
|
||||
chartSettings: { goalLines: undefined },
|
||||
}
|
||||
|
||||
// globalQuery represents the query object that is passed into the data
|
||||
// visualization logic and series breakdown logic it is modified by calls to
|
||||
// setQuery so we want to ensure this is updated correctly
|
||||
let globalQuery = { ...initialQuery }
|
||||
|
||||
const dummyDataVisualizationLogicProps: DataVisualizationLogicProps = {
|
||||
key: testUniqueKey,
|
||||
query: globalQuery,
|
||||
setQuery: (query) => {
|
||||
globalQuery = query
|
||||
},
|
||||
insightLogicProps: {
|
||||
cachedInsight: null,
|
||||
dashboardItemId: 'new-test-SQL',
|
||||
},
|
||||
}
|
||||
|
||||
describe('seriesBreakdownLogic', () => {
|
||||
let logic: ReturnType<typeof seriesBreakdownLogic.build>
|
||||
let builtDataVizLogic: ReturnType<typeof dataVisualizationLogic.build>
|
||||
|
||||
beforeEach(() => {
|
||||
initKeaTests()
|
||||
|
||||
// Mock prefersColorSchemeMedia to avoid TypeError
|
||||
// (this is a known issue with jest and window.matchMedia)
|
||||
// and is used by themeLogic
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
featureFlagLogic.mount()
|
||||
|
||||
// ensure we reset the globalQuery state before each test
|
||||
globalQuery = { ...initialQuery }
|
||||
|
||||
builtDataVizLogic = dataVisualizationLogic(dummyDataVisualizationLogicProps)
|
||||
builtDataVizLogic.mount()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logic?.unmount()
|
||||
builtDataVizLogic?.unmount()
|
||||
})
|
||||
|
||||
it('sets the correct values after mounting', async () => {
|
||||
logic = seriesBreakdownLogic({ key: testUniqueKey })
|
||||
logic.mount()
|
||||
|
||||
await expectLogic(logic).toMatchValues({
|
||||
showSeriesBreakdown: false,
|
||||
selectedSeriesBreakdownColumn: null,
|
||||
})
|
||||
|
||||
expect(globalQuery).toEqual({
|
||||
...initialQuery,
|
||||
chartSettings: {
|
||||
goalLines: undefined,
|
||||
seriesBreakdownColumn: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// this was an example of a previous bug where the series breakdown logic
|
||||
// would mount and override the existing query settings
|
||||
it('does not override existing query settings after mounting', async () => {
|
||||
// set visualization type to line graph and ensure this persists
|
||||
builtDataVizLogic.actions.setVisualizationType(ChartDisplayType.ActionsLineGraph)
|
||||
expect(globalQuery.display).toEqual(ChartDisplayType.ActionsLineGraph)
|
||||
|
||||
logic = seriesBreakdownLogic({ key: testUniqueKey })
|
||||
logic.mount()
|
||||
|
||||
await expectLogic(logic).toMatchValues({
|
||||
showSeriesBreakdown: false,
|
||||
selectedSeriesBreakdownColumn: null,
|
||||
})
|
||||
|
||||
expect(globalQuery).toEqual({
|
||||
...initialQuery,
|
||||
chartSettings: {
|
||||
goalLines: undefined,
|
||||
seriesBreakdownColumn: null,
|
||||
},
|
||||
display: ChartDisplayType.ActionsLineGraph,
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a series breakdown', async () => {
|
||||
logic = seriesBreakdownLogic({ key: testUniqueKey })
|
||||
logic.mount()
|
||||
|
||||
logic.actions.addSeriesBreakdown('test_column')
|
||||
await expectLogic(logic).toMatchValues({
|
||||
showSeriesBreakdown: true,
|
||||
selectedSeriesBreakdownColumn: 'test_column',
|
||||
})
|
||||
|
||||
expect(globalQuery).toEqual({
|
||||
...initialQuery,
|
||||
chartSettings: {
|
||||
goalLines: undefined,
|
||||
seriesBreakdownColumn: 'test_column',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a series breakdown after mount if one already selected in query', async () => {
|
||||
builtDataVizLogic.actions.setQuery({
|
||||
...initialQuery,
|
||||
chartSettings: {
|
||||
goalLines: undefined,
|
||||
seriesBreakdownColumn: 'test_column',
|
||||
},
|
||||
})
|
||||
|
||||
logic = seriesBreakdownLogic({ key: testUniqueKey })
|
||||
logic.mount()
|
||||
|
||||
await expectLogic(logic).toMatchValues({
|
||||
showSeriesBreakdown: true,
|
||||
selectedSeriesBreakdownColumn: 'test_column',
|
||||
})
|
||||
|
||||
expect(globalQuery).toEqual({
|
||||
...initialQuery,
|
||||
chartSettings: {
|
||||
goalLines: undefined,
|
||||
seriesBreakdownColumn: 'test_column',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a series breakdown', async () => {
|
||||
logic = seriesBreakdownLogic({ key: testUniqueKey })
|
||||
logic.mount()
|
||||
|
||||
logic.actions.addSeriesBreakdown('test_column')
|
||||
|
||||
logic.actions.deleteSeriesBreakdown()
|
||||
await expectLogic(logic).toMatchValues({
|
||||
showSeriesBreakdown: false,
|
||||
selectedSeriesBreakdownColumn: null,
|
||||
})
|
||||
|
||||
expect(globalQuery).toEqual({
|
||||
...initialQuery,
|
||||
chartSettings: {
|
||||
goalLines: undefined,
|
||||
seriesBreakdownColumn: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a series breakdown when clearAxis is called', async () => {
|
||||
logic = seriesBreakdownLogic({ key: testUniqueKey })
|
||||
logic.mount()
|
||||
|
||||
logic.actions.addSeriesBreakdown('test_column')
|
||||
|
||||
logic.actions.clearAxis()
|
||||
await expectLogic(logic).toMatchValues({
|
||||
showSeriesBreakdown: false,
|
||||
selectedSeriesBreakdownColumn: null,
|
||||
})
|
||||
|
||||
expect(globalQuery).toEqual({
|
||||
...initialQuery,
|
||||
tableSettings: {
|
||||
columns: [],
|
||||
conditionalFormatting: [],
|
||||
},
|
||||
chartSettings: {
|
||||
goalLines: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('computes the correct data', async () => {
|
||||
logic = seriesBreakdownLogic({ key: testUniqueKey })
|
||||
logic.mount()
|
||||
|
||||
const builtDataNodeLogic = dataNodeLogic({
|
||||
key: testUniqueKey,
|
||||
query: globalQuery.source,
|
||||
})
|
||||
builtDataNodeLogic.mount()
|
||||
builtDataNodeLogic.actions.setResponse({
|
||||
results: [
|
||||
['signed_up', 'Safari', 11],
|
||||
['signed_up', 'Firefox', 22],
|
||||
['signed_up', 'Chrome', 59],
|
||||
['logged_out', 'Safari', 32],
|
||||
['downloaded_file', 'Firefox', 820],
|
||||
['logged_out', 'Chrome', 173],
|
||||
['downloaded_file', 'Chrome', 2218],
|
||||
['downloaded_file', 'Safari', 282],
|
||||
['logged_out', 'Firefox', 60],
|
||||
],
|
||||
columns: ['event', 'browser', 'total_count'],
|
||||
types: [
|
||||
['event', 'String'],
|
||||
['browser', 'Nullable(String)'],
|
||||
['total_count', 'UInt64'],
|
||||
],
|
||||
})
|
||||
|
||||
builtDataVizLogic.actions.updateXSeries('event')
|
||||
|
||||
logic.actions.addSeriesBreakdown('browser')
|
||||
|
||||
await expectLogic(logic).toMatchValues({
|
||||
breakdownColumnValues: ['Safari', 'Firefox', 'Chrome'],
|
||||
seriesBreakdownData: {
|
||||
xData: {
|
||||
column: {
|
||||
name: 'event',
|
||||
type: { name: 'STRING', isNumerical: false },
|
||||
label: 'event - String',
|
||||
dataIndex: 0,
|
||||
},
|
||||
data: ['signed_up', 'logged_out', 'downloaded_file'],
|
||||
},
|
||||
seriesData: [
|
||||
{
|
||||
name: 'Safari',
|
||||
data: [11, 32, 282],
|
||||
settings: {
|
||||
formatting: { prefix: '', suffix: '' },
|
||||
display: { displayType: undefined, yAxisPosition: undefined },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Firefox',
|
||||
data: [22, 60, 820],
|
||||
settings: {
|
||||
formatting: { prefix: '', suffix: '' },
|
||||
display: { displayType: undefined, yAxisPosition: undefined },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Chrome',
|
||||
data: [59, 173, 2218],
|
||||
settings: {
|
||||
formatting: { prefix: '', suffix: '' },
|
||||
display: { displayType: undefined, yAxisPosition: undefined },
|
||||
},
|
||||
},
|
||||
],
|
||||
isUnaggregated: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
@ -2,19 +2,25 @@ import { IconX } from '@posthog/icons'
|
||||
import { LemonButton, LemonDivider } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { BurningMoneyHog } from 'lib/components/hedgehogs'
|
||||
import { FEATURE_FLAGS } from 'lib/constants'
|
||||
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
|
||||
import useResizeObserver from 'use-resize-observer'
|
||||
|
||||
import { billingLogic } from './billingLogic'
|
||||
import { PurchaseCreditsModal } from './PurchaseCreditsModal'
|
||||
|
||||
export const DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD = 500
|
||||
|
||||
export const CreditCTAHero = (): JSX.Element | null => {
|
||||
const { width, ref: heroRef } = useResizeObserver()
|
||||
const { featureFlags } = useValues(featureFlagLogic)
|
||||
|
||||
const { creditOverview, isPurchaseCreditsModalOpen, isCreditCTAHeroDismissed, computedDiscount } =
|
||||
useValues(billingLogic)
|
||||
const { showPurchaseCreditsModal, toggleCreditCTAHeroDismissed } = useActions(billingLogic)
|
||||
|
||||
if (!creditOverview.eligible || creditOverview.status === 'paid') {
|
||||
const isEligible = creditOverview.eligible || featureFlags[FEATURE_FLAGS.SELF_SERVE_CREDIT_OVERRIDE]
|
||||
if (creditOverview.status === 'paid' || !isEligible) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -37,6 +43,8 @@ export const CreditCTAHero = (): JSX.Element | null => {
|
||||
)
|
||||
}
|
||||
|
||||
const estimatedMonthlyCreditAmountUsd =
|
||||
creditOverview?.estimated_monthly_credit_amount_usd || DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD
|
||||
return (
|
||||
<div
|
||||
className="flex relative justify-between items-start rounded-lg bg-bg-light border mb-2 gap-2"
|
||||
@ -56,7 +64,7 @@ export const CreditCTAHero = (): JSX.Element | null => {
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 flex-1">
|
||||
{creditOverview.eligible && creditOverview.status === 'pending' && (
|
||||
{isEligible && creditOverview.status === 'pending' && (
|
||||
<>
|
||||
<h1 className="mb-0">We're applying your credits</h1>
|
||||
<p className="mt-2 mb-0 max-w-xl">
|
||||
@ -78,7 +86,7 @@ export const CreditCTAHero = (): JSX.Element | null => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{creditOverview.eligible && creditOverview.status === 'none' && (
|
||||
{isEligible && (!creditOverview || creditOverview.status === 'none') && (
|
||||
<>
|
||||
<h2 className="mb-0">
|
||||
Stop burning money.{' '}
|
||||
@ -87,20 +95,20 @@ export const CreditCTAHero = (): JSX.Element | null => {
|
||||
</h2>
|
||||
<p className="mt-2 mb-0 max-w-xl">
|
||||
Based on your usage, your monthly bill is forecasted to be an average of{' '}
|
||||
<strong>${creditOverview.estimated_monthly_credit_amount_usd.toFixed(0)}/month</strong> over
|
||||
the next year.
|
||||
<strong>${estimatedMonthlyCreditAmountUsd.toFixed(0)}/month</strong> over the next year.
|
||||
</p>
|
||||
<p className="mt-2 mb-0 max-w-xl">
|
||||
This qualifies you for a <strong>{computedDiscount * 100}% discount</strong> by
|
||||
pre-purchasing usage credits. Which gives you a net savings of{' '}
|
||||
<strong>
|
||||
$
|
||||
{Math.round(
|
||||
creditOverview.estimated_monthly_credit_amount_usd * computedDiscount * 12
|
||||
).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
{Math.round(estimatedMonthlyCreditAmountUsd * computedDiscount * 12).toLocaleString(
|
||||
'en-US',
|
||||
{
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}
|
||||
)}
|
||||
</strong>{' '}
|
||||
over the next year.
|
||||
</p>
|
||||
|
@ -71,18 +71,22 @@ export const PaymentEntryModal = ({
|
||||
const [stripePromise, setStripePromise] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Load Stripe.js asynchronously
|
||||
const loadStripeJs = async (): Promise<void> => {
|
||||
const { loadStripe } = await stripeJs()
|
||||
const publicKey = window.STRIPE_PUBLIC_KEY!
|
||||
setStripePromise(await loadStripe(publicKey))
|
||||
// Only load Stripe.js when the modal is opened
|
||||
if (paymentEntryModalOpen && !stripePromise) {
|
||||
const loadStripeJs = async (): Promise<void> => {
|
||||
const { loadStripe } = await stripeJs()
|
||||
const publicKey = window.STRIPE_PUBLIC_KEY!
|
||||
setStripePromise(await loadStripe(publicKey))
|
||||
}
|
||||
void loadStripeJs()
|
||||
}
|
||||
void loadStripeJs()
|
||||
}, [])
|
||||
}, [paymentEntryModalOpen, stripePromise])
|
||||
|
||||
useEffect(() => {
|
||||
initiateAuthorization(redirectPath)
|
||||
}, [initiateAuthorization, redirectPath])
|
||||
if (paymentEntryModalOpen) {
|
||||
initiateAuthorization(redirectPath)
|
||||
}
|
||||
}, [paymentEntryModalOpen, initiateAuthorization, redirectPath])
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
|
@ -8,6 +8,7 @@ import { LemonRadio } from 'lib/lemon-ui/LemonRadio'
|
||||
|
||||
import { BillingGauge } from './BillingGauge'
|
||||
import { billingLogic } from './billingLogic'
|
||||
import { DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD } from './CreditCTAHero'
|
||||
import { BillingGaugeItemKind } from './types'
|
||||
|
||||
export const PurchaseCreditsModal = (): JSX.Element | null => {
|
||||
@ -16,6 +17,8 @@ export const PurchaseCreditsModal = (): JSX.Element | null => {
|
||||
const { openSupportForm } = useActions(supportLogic)
|
||||
|
||||
const creditInputValue: number = +creditForm.creditInput || 0
|
||||
const estimatedMonthlyCreditAmountUsd =
|
||||
creditOverview.estimated_monthly_credit_amount_usd || DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD
|
||||
return (
|
||||
<LemonModal
|
||||
onClose={() => showPurchaseCreditsModal(false)}
|
||||
@ -56,7 +59,7 @@ export const PurchaseCreditsModal = (): JSX.Element | null => {
|
||||
Based on your usage, we think you'll use{' '}
|
||||
<b>
|
||||
$
|
||||
{(+creditOverview.estimated_monthly_credit_amount_usd).toLocaleString('en-US', {
|
||||
{(+estimatedMonthlyCreditAmountUsd).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
@ -64,7 +67,7 @@ export const PurchaseCreditsModal = (): JSX.Element | null => {
|
||||
of credits per month, for a total of{' '}
|
||||
<b>
|
||||
$
|
||||
{(+creditOverview.estimated_monthly_credit_amount_usd * 12).toLocaleString('en-US', {
|
||||
{(+estimatedMonthlyCreditAmountUsd * 12).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
|
@ -18,6 +18,7 @@ import { userLogic } from 'scenes/userLogic'
|
||||
import { BillingPlanType, BillingProductV2Type, BillingType, ProductKey } from '~/types'
|
||||
|
||||
import type { billingLogicType } from './billingLogicType'
|
||||
import { DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD } from './CreditCTAHero'
|
||||
|
||||
export const ALLOCATION_THRESHOLD_ALERT = 0.85 // Threshold to show warning of event usage near limit
|
||||
export const ALLOCATION_THRESHOLD_BLOCK = 1.2 // Threshold to block usage
|
||||
@ -325,7 +326,7 @@ export const billingLogic = kea<billingLogicType>([
|
||||
creditOverview: [
|
||||
{
|
||||
eligible: false,
|
||||
estimated_monthly_credit_amount_usd: 0,
|
||||
estimated_monthly_credit_amount_usd: DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD,
|
||||
status: 'none',
|
||||
invoice_url: null,
|
||||
collection_method: null,
|
||||
@ -340,7 +341,10 @@ export const billingLogic = kea<billingLogicType>([
|
||||
if (!values.creditForm.creditInput) {
|
||||
actions.setCreditFormValue(
|
||||
'creditInput',
|
||||
Math.round(response.estimated_monthly_credit_amount_usd * 12)
|
||||
Math.round(
|
||||
(response.estimated_monthly_credit_amount_usd ||
|
||||
DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD) * 12
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -352,7 +356,7 @@ export const billingLogic = kea<billingLogicType>([
|
||||
// Return default values if not subscribed
|
||||
return {
|
||||
eligible: false,
|
||||
estimated_monthly_credit_amount_usd: 0,
|
||||
estimated_monthly_credit_amount_usd: DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD,
|
||||
status: 'none',
|
||||
invoice_url: null,
|
||||
collection_method: null,
|
||||
@ -531,7 +535,8 @@ export const billingLogic = kea<billingLogicType>([
|
||||
posthog.capture('credits cta shown', {
|
||||
eligible: creditOverview.eligible,
|
||||
status: creditOverview.status,
|
||||
estimated_monthly_credit_amount_usd: creditOverview.estimated_monthly_credit_amount_usd,
|
||||
estimated_monthly_credit_amount_usd:
|
||||
creditOverview.estimated_monthly_credit_amount_usd || DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD,
|
||||
})
|
||||
},
|
||||
toggleCreditCTAHeroDismissed: ({ isDismissed }) => {
|
||||
|
@ -1,14 +1,23 @@
|
||||
import { BindLogic } from 'kea'
|
||||
import { IconArrowLeft } from '@posthog/icons'
|
||||
import { BindLogic, useActions, useValues } from 'kea'
|
||||
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
|
||||
import { DatabaseTableTree } from 'lib/components/DatabaseTableTree/DatabaseTableTree'
|
||||
import { LemonButton } from 'lib/lemon-ui/LemonButton'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { Sidebar } from '~/layout/navigation-3000/components/Sidebar'
|
||||
import { navigation3000Logic } from '~/layout/navigation-3000/navigationLogic'
|
||||
|
||||
import { editorSceneLogic } from './editorSceneLogic'
|
||||
import { editorSizingLogic } from './editorSizingLogic'
|
||||
import { QueryWindow } from './QueryWindow'
|
||||
import { SourceNavigator } from './SourceNavigator'
|
||||
|
||||
export function EditorScene(): JSX.Element {
|
||||
const ref = useRef(null)
|
||||
const navigatorRef = useRef(null)
|
||||
const queryPaneRef = useRef(null)
|
||||
const { activeNavbarItem } = useValues(navigation3000Logic)
|
||||
const { sidebarOverlayOpen } = useValues(editorSceneLogic)
|
||||
|
||||
const editorSizingLogicProps = {
|
||||
editorSceneRef: ref,
|
||||
@ -28,9 +37,41 @@ export function EditorScene(): JSX.Element {
|
||||
return (
|
||||
<BindLogic logic={editorSizingLogic} props={editorSizingLogicProps}>
|
||||
<div className="w-full h-full flex flex-row overflow-hidden" ref={ref}>
|
||||
<SourceNavigator />
|
||||
{activeNavbarItem && (
|
||||
<Sidebar
|
||||
key={activeNavbarItem.identifier}
|
||||
navbarItem={activeNavbarItem}
|
||||
sidebarOverlay={<EditorSidebarOverlay />}
|
||||
sidebarOverlayProps={{ isOpen: sidebarOverlayOpen }}
|
||||
/>
|
||||
)}
|
||||
<QueryWindow />
|
||||
</div>
|
||||
</BindLogic>
|
||||
)
|
||||
}
|
||||
|
||||
const EditorSidebarOverlay = (): JSX.Element => {
|
||||
const { setSidebarOverlayOpen } = useActions(editorSceneLogic)
|
||||
const { sidebarOverlayTreeItems, selectedSchema } = useValues(editorSceneLogic)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<header className="flex flex-row h-10 border-b shrink-0 p-1 gap-2">
|
||||
<LemonButton size="small" icon={<IconArrowLeft />} onClick={() => setSidebarOverlayOpen(false)} />
|
||||
{selectedSchema?.name && (
|
||||
<CopyToClipboardInline
|
||||
className="font-mono"
|
||||
tooltipMessage={null}
|
||||
description="schema"
|
||||
iconStyle={{ color: 'var(--muted-alt)' }}
|
||||
explicitValue={selectedSchema?.name}
|
||||
>
|
||||
{selectedSchema?.name}
|
||||
</CopyToClipboardInline>
|
||||
)}
|
||||
</header>
|
||||
<DatabaseTableTree items={sidebarOverlayTreeItems} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { useValues } from 'kea'
|
||||
import { Resizer } from 'lib/components/Resizer/Resizer'
|
||||
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
|
||||
import { CodeEditor, CodeEditorProps } from 'lib/monaco/CodeEditor'
|
||||
import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer'
|
||||
|
||||
@ -16,43 +15,44 @@ export function QueryPane(props: QueryPaneProps): JSX.Element {
|
||||
const { queryPaneHeight, queryPaneResizerProps } = useValues(editorSizingLogic)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col w-full bg-bg-3000"
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={{
|
||||
height: `${queryPaneHeight}px`,
|
||||
}}
|
||||
ref={queryPaneResizerProps.containerRef}
|
||||
>
|
||||
<div className="flex-1">
|
||||
{props.promptError ? <LemonBanner type="warning">{props.promptError}</LemonBanner> : null}
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<CodeEditor
|
||||
className="border"
|
||||
language="hogQL"
|
||||
value={props.queryInput}
|
||||
height={height}
|
||||
width={width}
|
||||
{...props.codeEditorProps}
|
||||
options={{
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
fixedOverflowWidgets: true,
|
||||
suggest: {
|
||||
showInlineDetails: true,
|
||||
},
|
||||
quickSuggestionsDelay: 300,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
<>
|
||||
<div
|
||||
className="relative flex flex-col w-full bg-bg-3000"
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={{
|
||||
height: `${queryPaneHeight}px`,
|
||||
}}
|
||||
ref={queryPaneResizerProps.containerRef}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<CodeEditor
|
||||
className="border"
|
||||
language="hogQL"
|
||||
value={props.queryInput}
|
||||
height={height}
|
||||
width={width}
|
||||
{...props.codeEditorProps}
|
||||
options={{
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
fixedOverflowWidgets: true,
|
||||
suggest: {
|
||||
showInlineDetails: true,
|
||||
},
|
||||
quickSuggestionsDelay: 300,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<Resizer {...queryPaneResizerProps} />
|
||||
</div>
|
||||
<Resizer {...queryPaneResizerProps} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,41 +1,42 @@
|
||||
import { IconPlus, IconX } from '@posthog/icons'
|
||||
import { LemonButton } from '@posthog/lemon-ui'
|
||||
import clsx from 'clsx'
|
||||
import { Uri } from 'monaco-editor'
|
||||
|
||||
import { QueryTab } from './multitabEditorLogic'
|
||||
|
||||
interface QueryTabsProps {
|
||||
models: Uri[]
|
||||
onClick: (model: Uri) => void
|
||||
onClear: (model: Uri) => void
|
||||
models: QueryTab[]
|
||||
onClick: (model: QueryTab) => void
|
||||
onClear: (model: QueryTab) => void
|
||||
onAdd: () => void
|
||||
activeModelUri: Uri | null
|
||||
activeModelUri: QueryTab | null
|
||||
}
|
||||
|
||||
export function QueryTabs({ models, onClear, onClick, onAdd, activeModelUri }: QueryTabsProps): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-row overflow-scroll hide-scrollbar">
|
||||
{models.map((model: Uri) => (
|
||||
<QueryTab
|
||||
key={model.path}
|
||||
<div className="flex flex-row overflow-scroll hide-scrollbar h-10">
|
||||
{models.map((model: QueryTab) => (
|
||||
<QueryTabComponent
|
||||
key={model.uri.path}
|
||||
model={model}
|
||||
onClear={models.length > 1 ? onClear : undefined}
|
||||
onClick={onClick}
|
||||
active={activeModelUri?.path === model.path}
|
||||
active={activeModelUri?.uri.path === model.uri.path}
|
||||
/>
|
||||
))}
|
||||
<LemonButton onClick={onAdd} icon={<IconPlus fontSize={14} />} />
|
||||
<LemonButton onClick={() => onAdd()} icon={<IconPlus fontSize={14} />} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface QueryTabProps {
|
||||
model: Uri
|
||||
onClick: (model: Uri) => void
|
||||
onClear?: (model: Uri) => void
|
||||
model: QueryTab
|
||||
onClick: (model: QueryTab) => void
|
||||
onClear?: (model: QueryTab) => void
|
||||
active: boolean
|
||||
}
|
||||
|
||||
function QueryTab({ model, active, onClear, onClick }: QueryTabProps): JSX.Element {
|
||||
function QueryTabComponent({ model, active, onClear, onClick }: QueryTabProps): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onClick?.(model)}
|
||||
@ -45,7 +46,7 @@ function QueryTab({ model, active, onClear, onClick }: QueryTabProps): JSX.Eleme
|
||||
onClear ? 'pl-3 pr-2' : 'px-3'
|
||||
)}
|
||||
>
|
||||
Untitled
|
||||
{model.view?.name ?? 'Untitled'}
|
||||
{onClear && (
|
||||
<LemonButton
|
||||
onClick={(e) => {
|
||||
|
@ -22,8 +22,17 @@ export function QueryWindow(): JSX.Element {
|
||||
monaco,
|
||||
editor,
|
||||
})
|
||||
const { allTabs, activeModelUri, queryInput, activeQuery, activeTabKey, hasErrors, error, isValidView } =
|
||||
useValues(logic)
|
||||
const {
|
||||
allTabs,
|
||||
activeModelUri,
|
||||
queryInput,
|
||||
activeQuery,
|
||||
activeTabKey,
|
||||
hasErrors,
|
||||
error,
|
||||
isValidView,
|
||||
editingView,
|
||||
} = useValues(logic)
|
||||
const { selectTab, deleteTab, createTab, setQueryInput, runQuery, saveAsView } = useActions(logic)
|
||||
|
||||
return (
|
||||
@ -35,6 +44,11 @@ export function QueryWindow(): JSX.Element {
|
||||
onAdd={createTab}
|
||||
activeModelUri={activeModelUri}
|
||||
/>
|
||||
{editingView && (
|
||||
<div className="h-7 bg-warning-highlight p-1">
|
||||
<span> Editing view "{editingView.name}"</span>
|
||||
</div>
|
||||
)}
|
||||
<QueryPane
|
||||
queryInput={queryInput}
|
||||
promptError={null}
|
||||
|
@ -1,14 +1,19 @@
|
||||
import 'react-data-grid/lib/styles.css'
|
||||
|
||||
import { LemonButton, LemonTabs, Spinner } from '@posthog/lemon-ui'
|
||||
import { useValues } from 'kea'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { router } from 'kea-router'
|
||||
import { useMemo } from 'react'
|
||||
import DataGrid from 'react-data-grid'
|
||||
|
||||
import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut'
|
||||
import { themeLogic } from '~/layout/navigation-3000/themeLogic'
|
||||
import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic'
|
||||
import { NodeKind } from '~/queries/schema'
|
||||
|
||||
import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic'
|
||||
import { multitabEditorLogic } from './multitabEditorLogic'
|
||||
|
||||
enum ResultsTab {
|
||||
Results = 'results',
|
||||
Visualization = 'visualization',
|
||||
@ -29,6 +34,13 @@ export function ResultPane({
|
||||
logicKey,
|
||||
query,
|
||||
}: ResultPaneProps): JSX.Element {
|
||||
const codeEditorKey = `hogQLQueryEditor/${router.values.location.pathname}`
|
||||
|
||||
const { editingView, queryInput } = useValues(
|
||||
multitabEditorLogic({
|
||||
key: codeEditorKey,
|
||||
})
|
||||
)
|
||||
const { isDarkModeOn } = useValues(themeLogic)
|
||||
const { response, responseLoading } = useValues(
|
||||
dataNodeLogic({
|
||||
@ -40,6 +52,8 @@ export function ResultPane({
|
||||
doNotLoad: !query,
|
||||
})
|
||||
)
|
||||
const { dataWarehouseSavedQueriesLoading } = useValues(dataWarehouseViewsLogic)
|
||||
const { updateDataWarehouseSavedQuery } = useActions(dataWarehouseViewsLogic)
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return (
|
||||
@ -78,11 +92,32 @@ export function ResultPane({
|
||||
]}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<LemonButton type="secondary" onClick={() => onSave()} disabledReason={saveDisabledReason}>
|
||||
Save
|
||||
</LemonButton>
|
||||
<LemonButton type="primary" onClick={() => onQueryInputChange()}>
|
||||
Run
|
||||
{editingView ? (
|
||||
<>
|
||||
<LemonButton
|
||||
loading={dataWarehouseSavedQueriesLoading}
|
||||
type="secondary"
|
||||
onClick={() =>
|
||||
updateDataWarehouseSavedQuery({
|
||||
id: editingView.id,
|
||||
query: {
|
||||
kind: NodeKind.HogQLQuery,
|
||||
query: queryInput,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Update
|
||||
</LemonButton>
|
||||
</>
|
||||
) : (
|
||||
<LemonButton type="secondary" onClick={() => onSave()} disabledReason={saveDisabledReason}>
|
||||
Save
|
||||
</LemonButton>
|
||||
)}
|
||||
<LemonButton loading={responseLoading} type="primary" onClick={() => onQueryInputChange()}>
|
||||
<span className="mr-1">Run</span>
|
||||
<KeyboardShortcut command enter />
|
||||
</LemonButton>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,25 +0,0 @@
|
||||
import { useValues } from 'kea'
|
||||
import { Resizer } from 'lib/components/Resizer/Resizer'
|
||||
|
||||
import { DatabaseTableTreeWithItems } from '../external/DataWarehouseTables'
|
||||
import { editorSizingLogic } from './editorSizingLogic'
|
||||
import { SchemaSearch } from './SchemaSearch'
|
||||
|
||||
export function SourceNavigator(): JSX.Element {
|
||||
const { sourceNavigatorWidth, sourceNavigatorResizerProps } = useValues(editorSizingLogic)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sourceNavigatorResizerProps.containerRef}
|
||||
className="relative flex flex-col bg-bg-3000 h-full overflow-hidden"
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={{
|
||||
width: `${sourceNavigatorWidth}px`,
|
||||
}}
|
||||
>
|
||||
<SchemaSearch />
|
||||
<DatabaseTableTreeWithItems inline collapsible={false} />
|
||||
<Resizer {...sourceNavigatorResizerProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
import { actions, kea, path, reducers, selectors } from 'kea'
|
||||
import { TreeItem } from 'lib/components/DatabaseTableTree/DatabaseTableTree'
|
||||
|
||||
import { DatabaseSchemaDataWarehouseTable, DatabaseSchemaTable } from '~/queries/schema'
|
||||
import { DataWarehouseSavedQuery } from '~/types'
|
||||
|
||||
import type { editorSceneLogicType } from './editorSceneLogicType'
|
||||
|
||||
export const editorSceneLogic = kea<editorSceneLogicType>([
|
||||
path(['scenes', 'data-warehouse', 'editor', 'editorSceneLogic']),
|
||||
actions({
|
||||
setSidebarOverlayOpen: (isOpen: boolean) => ({ isOpen }),
|
||||
selectSchema: (schema: DatabaseSchemaDataWarehouseTable | DatabaseSchemaTable | DataWarehouseSavedQuery) => ({
|
||||
schema,
|
||||
}),
|
||||
}),
|
||||
reducers({
|
||||
sidebarOverlayOpen: [
|
||||
false,
|
||||
{
|
||||
setSidebarOverlayOpen: (_, { isOpen }) => isOpen,
|
||||
selectSchema: (_, { schema }) => schema !== null,
|
||||
},
|
||||
],
|
||||
selectedSchema: [
|
||||
null as DatabaseSchemaDataWarehouseTable | DatabaseSchemaTable | DataWarehouseSavedQuery | null,
|
||||
{
|
||||
selectSchema: (_, { schema }) => schema,
|
||||
},
|
||||
],
|
||||
}),
|
||||
selectors({
|
||||
sidebarOverlayTreeItems: [
|
||||
(s) => [s.selectedSchema],
|
||||
(selectedSchema): TreeItem[] => {
|
||||
if (selectedSchema === null) {
|
||||
return []
|
||||
}
|
||||
if ('fields' in selectedSchema) {
|
||||
return Object.values(selectedSchema.fields).map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
}))
|
||||
}
|
||||
|
||||
if ('columns' in selectedSchema) {
|
||||
return Object.values(selectedSchema.columns).map((column) => ({
|
||||
name: column.name,
|
||||
type: column.type,
|
||||
}))
|
||||
}
|
||||
return []
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
203
frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import Fuse from 'fuse.js'
|
||||
import { connect, kea, path, selectors } from 'kea'
|
||||
import { router } from 'kea-router'
|
||||
import { subscriptions } from 'kea-subscriptions'
|
||||
import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic'
|
||||
import { sceneLogic } from 'scenes/sceneLogic'
|
||||
import { Scene } from 'scenes/sceneTypes'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { navigation3000Logic } from '~/layout/navigation-3000/navigationLogic'
|
||||
import { FuseSearchMatch } from '~/layout/navigation-3000/sidebars/utils'
|
||||
import { SidebarCategory } from '~/layout/navigation-3000/types'
|
||||
import { DatabaseSchemaDataWarehouseTable, DatabaseSchemaTable } from '~/queries/schema'
|
||||
import { DataWarehouseSavedQuery, PipelineTab } from '~/types'
|
||||
|
||||
import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic'
|
||||
import { editorSceneLogic } from './editorSceneLogic'
|
||||
import type { editorSidebarLogicType } from './editorSidebarLogicType'
|
||||
import { multitabEditorLogic } from './multitabEditorLogic'
|
||||
|
||||
const dataWarehouseTablesfuse = new Fuse<DatabaseSchemaDataWarehouseTable>([], {
|
||||
keys: [{ name: 'name', weight: 2 }],
|
||||
threshold: 0.3,
|
||||
ignoreLocation: true,
|
||||
includeMatches: true,
|
||||
})
|
||||
|
||||
const posthogTablesfuse = new Fuse<DatabaseSchemaTable>([], {
|
||||
keys: [{ name: 'name', weight: 2 }],
|
||||
threshold: 0.3,
|
||||
ignoreLocation: true,
|
||||
includeMatches: true,
|
||||
})
|
||||
|
||||
const savedQueriesfuse = new Fuse<DataWarehouseSavedQuery>([], {
|
||||
keys: [{ name: 'name', weight: 2 }],
|
||||
threshold: 0.3,
|
||||
ignoreLocation: true,
|
||||
includeMatches: true,
|
||||
})
|
||||
|
||||
export const editorSidebarLogic = kea<editorSidebarLogicType>([
|
||||
path(['data-warehouse', 'editor', 'editorSidebarLogic']),
|
||||
connect({
|
||||
values: [
|
||||
sceneLogic,
|
||||
['activeScene', 'sceneParams'],
|
||||
dataWarehouseViewsLogic,
|
||||
['dataWarehouseSavedQueries', 'dataWarehouseSavedQueryMapById', 'dataWarehouseSavedQueriesLoading'],
|
||||
databaseTableListLogic,
|
||||
['posthogTables', 'dataWarehouseTables', 'databaseLoading', 'views', 'viewsMapById'],
|
||||
],
|
||||
actions: [editorSceneLogic, ['selectSchema'], dataWarehouseViewsLogic, ['deleteDataWarehouseSavedQuery']],
|
||||
}),
|
||||
selectors(({ actions }) => ({
|
||||
contents: [
|
||||
(s) => [
|
||||
s.relevantSavedQueries,
|
||||
s.dataWarehouseSavedQueriesLoading,
|
||||
s.relevantPosthogTables,
|
||||
s.relevantDataWarehouseTables,
|
||||
s.databaseLoading,
|
||||
],
|
||||
(
|
||||
relevantSavedQueries,
|
||||
dataWarehouseSavedQueriesLoading,
|
||||
relevantPosthogTables,
|
||||
relevantDataWarehouseTables,
|
||||
databaseLoading
|
||||
) => [
|
||||
{
|
||||
key: 'data-warehouse-sources',
|
||||
noun: ['source', 'external source'],
|
||||
loading: databaseLoading,
|
||||
items: relevantDataWarehouseTables.map(([table, matches]) => ({
|
||||
key: table.id,
|
||||
name: table.name,
|
||||
url: '',
|
||||
searchMatch: matches
|
||||
? {
|
||||
matchingFields: matches.map((match) => match.key),
|
||||
nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices,
|
||||
}
|
||||
: null,
|
||||
onClick: () => {
|
||||
actions.selectSchema(table)
|
||||
},
|
||||
})),
|
||||
onAdd: () => {
|
||||
router.actions.push(urls.pipeline(PipelineTab.Sources))
|
||||
},
|
||||
} as SidebarCategory,
|
||||
{
|
||||
key: 'data-warehouse-tables',
|
||||
noun: ['table', 'tables'],
|
||||
loading: databaseLoading,
|
||||
items: relevantPosthogTables.map(([table, matches]) => ({
|
||||
key: table.id,
|
||||
name: table.name,
|
||||
url: '',
|
||||
searchMatch: matches
|
||||
? {
|
||||
matchingFields: matches.map((match) => match.key),
|
||||
nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices,
|
||||
}
|
||||
: null,
|
||||
onClick: () => {
|
||||
actions.selectSchema(table)
|
||||
},
|
||||
})),
|
||||
} as SidebarCategory,
|
||||
{
|
||||
key: 'data-warehouse-views',
|
||||
noun: ['view', 'views'],
|
||||
loading: dataWarehouseSavedQueriesLoading,
|
||||
items: relevantSavedQueries.map(([savedQuery, matches]) => ({
|
||||
key: savedQuery.id,
|
||||
name: savedQuery.name,
|
||||
url: '',
|
||||
searchMatch: matches
|
||||
? {
|
||||
matchingFields: matches.map((match) => match.key),
|
||||
nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices,
|
||||
}
|
||||
: null,
|
||||
onClick: () => {
|
||||
actions.selectSchema(savedQuery)
|
||||
},
|
||||
menuItems: [
|
||||
{
|
||||
label: 'Edit view definition',
|
||||
onClick: () => {
|
||||
multitabEditorLogic({
|
||||
key: `hogQLQueryEditor/${router.values.location.pathname}`,
|
||||
}).actions.createTab(savedQuery.query.query, savedQuery)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
status: 'danger',
|
||||
onClick: () => {
|
||||
actions.deleteDataWarehouseSavedQuery(savedQuery.id)
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
} as SidebarCategory,
|
||||
],
|
||||
],
|
||||
activeListItemKey: [
|
||||
(s) => [s.activeScene, s.sceneParams],
|
||||
(activeScene, sceneParams): [string, number] | null => {
|
||||
return activeScene === Scene.DataWarehouse && sceneParams.params.id
|
||||
? ['saved-queries', parseInt(sceneParams.params.id)]
|
||||
: null
|
||||
},
|
||||
],
|
||||
relevantDataWarehouseTables: [
|
||||
(s) => [s.dataWarehouseTables, navigation3000Logic.selectors.searchTerm],
|
||||
(dataWarehouseTables, searchTerm): [DatabaseSchemaDataWarehouseTable, FuseSearchMatch[] | null][] => {
|
||||
if (searchTerm) {
|
||||
return dataWarehouseTablesfuse
|
||||
.search(searchTerm)
|
||||
.map((result) => [result.item, result.matches as FuseSearchMatch[]])
|
||||
}
|
||||
return dataWarehouseTables.map((table) => [table, null])
|
||||
},
|
||||
],
|
||||
relevantPosthogTables: [
|
||||
(s) => [s.posthogTables, navigation3000Logic.selectors.searchTerm],
|
||||
(posthogTables, searchTerm): [DatabaseSchemaTable, FuseSearchMatch[] | null][] => {
|
||||
if (searchTerm) {
|
||||
return posthogTablesfuse
|
||||
.search(searchTerm)
|
||||
.map((result) => [result.item, result.matches as FuseSearchMatch[]])
|
||||
}
|
||||
return posthogTables.map((table) => [table, null])
|
||||
},
|
||||
],
|
||||
relevantSavedQueries: [
|
||||
(s) => [s.dataWarehouseSavedQueries, navigation3000Logic.selectors.searchTerm],
|
||||
(dataWarehouseSavedQueries, searchTerm): [DataWarehouseSavedQuery, FuseSearchMatch[] | null][] => {
|
||||
if (searchTerm) {
|
||||
return savedQueriesfuse
|
||||
.search(searchTerm)
|
||||
.map((result) => [result.item, result.matches as FuseSearchMatch[]])
|
||||
}
|
||||
return dataWarehouseSavedQueries.map((savedQuery) => [savedQuery, null])
|
||||
},
|
||||
],
|
||||
})),
|
||||
subscriptions({
|
||||
dataWarehouseTables: (dataWarehouseTables) => {
|
||||
dataWarehouseTablesfuse.setCollection(dataWarehouseTables)
|
||||
},
|
||||
posthogTables: (posthogTables) => {
|
||||
posthogTablesfuse.setCollection(posthogTables)
|
||||
},
|
||||
dataWarehouseSavedQueries: (dataWarehouseSavedQueries) => {
|
||||
savedQueriesfuse.setCollection(dataWarehouseSavedQueries)
|
||||
},
|
||||
}),
|
||||
])
|
@ -1,6 +1,6 @@
|
||||
import { Monaco } from '@monaco-editor/react'
|
||||
import { LemonDialog, LemonInput } from '@posthog/lemon-ui'
|
||||
import { actions, kea, listeners, path, props, propsChanged, reducers, selectors } from 'kea'
|
||||
import { LemonDialog, LemonInput, lemonToast } from '@posthog/lemon-ui'
|
||||
import { actions, connect, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea'
|
||||
import { subscriptions } from 'kea-subscriptions'
|
||||
import { LemonField } from 'lib/lemon-ui/LemonField'
|
||||
import { ModelMarker } from 'lib/monaco/codeEditorLogic'
|
||||
@ -9,6 +9,7 @@ import { editor, MarkerSeverity, Uri } from 'monaco-editor'
|
||||
import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic'
|
||||
import { performQuery } from '~/queries/query'
|
||||
import { HogLanguage, HogQLMetadata, HogQLMetadataResponse, HogQLNotice, HogQLQuery, NodeKind } from '~/queries/schema'
|
||||
import { DataWarehouseSavedQuery } from '~/types'
|
||||
|
||||
import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic'
|
||||
import type { multitabEditorLogicType } from './multitabEditorLogicType'
|
||||
@ -22,29 +23,41 @@ export interface MultitabEditorLogicProps {
|
||||
export const editorModelsStateKey = (key: string | number): string => `${key}/editorModelQueries`
|
||||
export const activemodelStateKey = (key: string | number): string => `${key}/activeModelUri`
|
||||
|
||||
export interface QueryTab {
|
||||
uri: Uri
|
||||
view?: DataWarehouseSavedQuery
|
||||
}
|
||||
|
||||
export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
path(['data-warehouse', 'editor', 'multitabEditorLogic']),
|
||||
props({} as MultitabEditorLogicProps),
|
||||
key((props) => props.key),
|
||||
connect({
|
||||
actions: [
|
||||
dataWarehouseViewsLogic,
|
||||
['deleteDataWarehouseSavedQuerySuccess', 'createDataWarehouseSavedQuerySuccess'],
|
||||
],
|
||||
}),
|
||||
actions({
|
||||
setQueryInput: (queryInput: string) => ({ queryInput }),
|
||||
updateState: true,
|
||||
runQuery: (queryOverride?: string) => ({ queryOverride }),
|
||||
setActiveQuery: (query: string) => ({ query }),
|
||||
setTabs: (tabs: Uri[]) => ({ tabs }),
|
||||
addTab: (tab: Uri) => ({ tab }),
|
||||
createTab: () => null,
|
||||
deleteTab: (tab: Uri) => ({ tab }),
|
||||
removeTab: (tab: Uri) => ({ tab }),
|
||||
selectTab: (tab: Uri) => ({ tab }),
|
||||
setTabs: (tabs: QueryTab[]) => ({ tabs }),
|
||||
addTab: (tab: QueryTab) => ({ tab }),
|
||||
createTab: (query?: string, view?: DataWarehouseSavedQuery) => ({ query, view }),
|
||||
deleteTab: (tab: QueryTab) => ({ tab }),
|
||||
removeTab: (tab: QueryTab) => ({ tab }),
|
||||
selectTab: (tab: QueryTab) => ({ tab }),
|
||||
setLocalState: (key: string, value: any) => ({ key, value }),
|
||||
initialize: true,
|
||||
saveAsView: true,
|
||||
saveAsViewSuccess: (name: string) => ({ name }),
|
||||
saveAsViewSubmit: (name: string) => ({ name }),
|
||||
reloadMetadata: true,
|
||||
setMetadata: (query: string, metadata: HogQLMetadataResponse) => ({ query, metadata }),
|
||||
}),
|
||||
propsChanged(({ actions }, oldProps) => {
|
||||
if (!oldProps.monaco && !oldProps.editor) {
|
||||
propsChanged(({ actions, props }, oldProps) => {
|
||||
if (!oldProps.monaco && !oldProps.editor && props.monaco && props.editor) {
|
||||
actions.initialize()
|
||||
}
|
||||
}),
|
||||
@ -62,20 +75,26 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
},
|
||||
],
|
||||
activeModelUri: [
|
||||
null as Uri | null,
|
||||
null as QueryTab | null,
|
||||
{
|
||||
selectTab: (_, { tab }) => tab,
|
||||
},
|
||||
],
|
||||
editingView: [
|
||||
null as DataWarehouseSavedQuery | null,
|
||||
{
|
||||
selectTab: (_, { tab }) => tab.view ?? null,
|
||||
},
|
||||
],
|
||||
allTabs: [
|
||||
[] as Uri[],
|
||||
[] as QueryTab[],
|
||||
{
|
||||
addTab: (state, { tab }) => {
|
||||
const newTabs = [...state, tab]
|
||||
return newTabs
|
||||
},
|
||||
removeTab: (state, { tab: tabToRemove }) => {
|
||||
const newModels = state.filter((tab) => tab.toString() !== tabToRemove.toString())
|
||||
const newModels = state.filter((tab) => tab.uri.toString() !== tabToRemove.uri.toString())
|
||||
return newModels
|
||||
},
|
||||
setTabs: (_, { tabs }) => tabs,
|
||||
@ -130,25 +149,32 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
},
|
||||
],
|
||||
})),
|
||||
listeners(({ values, props, actions }) => ({
|
||||
createTab: () => {
|
||||
listeners(({ values, props, actions, asyncActions }) => ({
|
||||
createTab: ({ query = '', view }) => {
|
||||
let currentModelCount = 1
|
||||
const allNumbers = values.allTabs.map((tab) => parseInt(tab.path.split('/').pop() || '0'))
|
||||
const allNumbers = values.allTabs.map((tab) => parseInt(tab.uri.path.split('/').pop() || '0'))
|
||||
while (allNumbers.includes(currentModelCount)) {
|
||||
currentModelCount++
|
||||
}
|
||||
|
||||
if (props.monaco) {
|
||||
const uri = props.monaco.Uri.parse(currentModelCount.toString())
|
||||
const model = props.monaco.editor.createModel('', 'hogQL', uri)
|
||||
const model = props.monaco.editor.createModel(query, 'hogQL', uri)
|
||||
props.editor?.setModel(model)
|
||||
actions.addTab(uri)
|
||||
actions.selectTab(uri)
|
||||
actions.addTab({
|
||||
uri,
|
||||
view,
|
||||
})
|
||||
actions.selectTab({
|
||||
uri,
|
||||
view,
|
||||
})
|
||||
|
||||
const queries = values.allTabs.map((tab) => {
|
||||
return {
|
||||
query: props.monaco?.editor.getModel(tab)?.getValue() || '',
|
||||
path: tab.path.split('/').pop(),
|
||||
query: props.monaco?.editor.getModel(tab.uri)?.getValue() || '',
|
||||
path: tab.uri.path.split('/').pop(),
|
||||
view: uri.path === tab.uri.path ? view : tab.view,
|
||||
}
|
||||
})
|
||||
actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries))
|
||||
@ -156,18 +182,20 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
},
|
||||
selectTab: ({ tab }) => {
|
||||
if (props.monaco) {
|
||||
const model = props.monaco.editor.getModel(tab)
|
||||
const model = props.monaco.editor.getModel(tab.uri)
|
||||
props.editor?.setModel(model)
|
||||
}
|
||||
|
||||
const path = tab.path.split('/').pop()
|
||||
const path = tab.uri.path.split('/').pop()
|
||||
path && actions.setLocalState(activemodelStateKey(props.key), path)
|
||||
},
|
||||
deleteTab: ({ tab: tabToRemove }) => {
|
||||
if (props.monaco) {
|
||||
const model = props.monaco.editor.getModel(tabToRemove)
|
||||
if (tabToRemove == values.activeModelUri) {
|
||||
const indexOfModel = values.allTabs.findIndex((tab) => tab.toString() === tabToRemove.toString())
|
||||
const model = props.monaco.editor.getModel(tabToRemove.uri)
|
||||
if (tabToRemove.uri.toString() === values.activeModelUri?.uri.toString()) {
|
||||
const indexOfModel = values.allTabs.findIndex(
|
||||
(tab) => tab.uri.toString() === tabToRemove.uri.toString()
|
||||
)
|
||||
const nextModel =
|
||||
values.allTabs[indexOfModel + 1] || values.allTabs[indexOfModel - 1] || values.allTabs[0] // there will always be one
|
||||
actions.selectTab(nextModel)
|
||||
@ -176,8 +204,9 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
actions.removeTab(tabToRemove)
|
||||
const queries = values.allTabs.map((tab) => {
|
||||
return {
|
||||
query: props.monaco?.editor.getModel(tab)?.getValue() || '',
|
||||
path: tab.path.split('/').pop(),
|
||||
query: props.monaco?.editor.getModel(tab.uri)?.getValue() || '',
|
||||
path: tab.uri.path.split('/').pop(),
|
||||
view: tab.view,
|
||||
}
|
||||
})
|
||||
actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries))
|
||||
@ -197,14 +226,17 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
})
|
||||
|
||||
const models = JSON.parse(allModelQueries || '[]')
|
||||
const newModels: Uri[] = []
|
||||
const newModels: QueryTab[] = []
|
||||
|
||||
models.forEach((model: Record<string, any>) => {
|
||||
if (props.monaco) {
|
||||
const uri = props.monaco.Uri.parse(model.path)
|
||||
const newModel = props.monaco.editor.createModel(model.query, 'hogQL', uri)
|
||||
props.editor?.setModel(newModel)
|
||||
newModels.push(uri)
|
||||
newModels.push({
|
||||
uri,
|
||||
view: model.view,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@ -221,9 +253,17 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
actions.setQueryInput(val)
|
||||
actions.runQuery()
|
||||
}
|
||||
uri && actions.selectTab(uri)
|
||||
const activeView = newModels.find((tab) => tab.uri.path.split('/').pop() === activeModelUri)?.view
|
||||
|
||||
uri &&
|
||||
actions.selectTab({
|
||||
uri,
|
||||
view: activeView,
|
||||
})
|
||||
} else if (newModels.length) {
|
||||
actions.selectTab(newModels[0])
|
||||
actions.selectTab({
|
||||
uri: newModels[0].uri,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const model = props.editor?.getModel()
|
||||
@ -240,13 +280,23 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
await breakpoint(100)
|
||||
const queries = values.allTabs.map((model) => {
|
||||
return {
|
||||
query: props.monaco?.editor.getModel(model)?.getValue() || '',
|
||||
path: model.path.split('/').pop(),
|
||||
query: props.monaco?.editor.getModel(model.uri)?.getValue() || '',
|
||||
path: model.uri.path.split('/').pop(),
|
||||
view: model.view,
|
||||
}
|
||||
})
|
||||
localStorage.setItem(editorModelsStateKey(props.key), JSON.stringify(queries))
|
||||
},
|
||||
runQuery: ({ queryOverride }) => {
|
||||
if (values.activeQuery === queryOverride || values.activeQuery === values.queryInput) {
|
||||
dataNodeLogic({
|
||||
key: values.activeTabKey,
|
||||
query: {
|
||||
kind: NodeKind.HogQLQuery,
|
||||
query: queryOverride || values.queryInput,
|
||||
},
|
||||
}).actions.loadData(true)
|
||||
}
|
||||
actions.setActiveQuery(queryOverride || values.queryInput)
|
||||
},
|
||||
saveAsView: async () => {
|
||||
@ -261,10 +311,13 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
errors: {
|
||||
viewName: (name) => (!name ? 'You must enter a name' : undefined),
|
||||
},
|
||||
onSubmit: ({ viewName }) => actions.saveAsViewSuccess(viewName),
|
||||
onSubmit: async ({ viewName }) => {
|
||||
await asyncActions.saveAsViewSubmit(viewName)
|
||||
},
|
||||
shouldAwaitSubmit: true,
|
||||
})
|
||||
},
|
||||
saveAsViewSuccess: async ({ name }) => {
|
||||
saveAsViewSubmit: async ({ name }) => {
|
||||
const query: HogQLQuery = {
|
||||
kind: NodeKind.HogQLQuery,
|
||||
query: values.queryInput,
|
||||
@ -290,11 +343,34 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
breakpoint()
|
||||
actions.setMetadata(query, response)
|
||||
},
|
||||
deleteDataWarehouseSavedQuerySuccess: ({ payload: viewId }) => {
|
||||
const tabToRemove = values.allTabs.find((tab) => tab.view?.id === viewId)
|
||||
if (tabToRemove) {
|
||||
actions.deleteTab(tabToRemove)
|
||||
}
|
||||
lemonToast.success('View deleted')
|
||||
},
|
||||
createDataWarehouseSavedQuerySuccess: ({ dataWarehouseSavedQueries, payload: view }) => {
|
||||
const newView = view && dataWarehouseSavedQueries.find((v) => v.name === view.name)
|
||||
if (newView) {
|
||||
const newTabs = values.allTabs.map((tab) => ({
|
||||
...tab,
|
||||
view: tab.uri.path === values.activeModelUri?.uri.path ? newView : tab.view,
|
||||
}))
|
||||
const newTab = newTabs.find((tab) => tab.uri.path === values.activeModelUri?.uri.path)
|
||||
actions.setTabs(newTabs)
|
||||
newTab && actions.selectTab(newTab)
|
||||
actions.updateState()
|
||||
}
|
||||
},
|
||||
updateDataWarehouseSavedQuerySuccess: () => {
|
||||
lemonToast.success('View updated')
|
||||
},
|
||||
})),
|
||||
subscriptions(({ props, actions, values }) => ({
|
||||
activeModelUri: (activeModelUri) => {
|
||||
if (props.monaco) {
|
||||
const _model = props.monaco.editor.getModel(activeModelUri)
|
||||
const _model = props.monaco.editor.getModel(activeModelUri.uri)
|
||||
const val = _model?.getValue()
|
||||
actions.setQueryInput(val ?? '')
|
||||
actions.runQuery()
|
||||
@ -313,7 +389,7 @@ export const multitabEditorLogic = kea<multitabEditorLogicType>([
|
||||
},
|
||||
})),
|
||||
selectors({
|
||||
activeTabKey: [(s) => [s.activeModelUri], (activeModelUri) => `hogQLQueryEditor/${activeModelUri?.path}`],
|
||||
activeTabKey: [(s) => [s.activeModelUri], (activeModelUri) => `hogQLQueryEditor/${activeModelUri?.uri.path}`],
|
||||
isValidView: [(s) => [s.metadata], (metadata) => !!(metadata && metadata[1]?.isValidView)],
|
||||
hasErrors: [
|
||||
(s) => [s.modelMarkers],
|
||||
|
@ -1,18 +0,0 @@
|
||||
import { kea } from 'kea'
|
||||
|
||||
import type { sourceNavigatorLogicType } from './sourceNavigatorLogicType'
|
||||
|
||||
export const sourceNavigatorLogic = kea<sourceNavigatorLogicType>({
|
||||
path: ['scenes', 'data-warehouse', 'editor', 'sourceNavigatorLogic'],
|
||||
actions: {
|
||||
setWidth: (width: number) => ({ width }),
|
||||
},
|
||||
reducers: {
|
||||
navigatorWidth: [
|
||||
200,
|
||||
{
|
||||
setWidth: (_, { width }: { width: number }) => width,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
@ -46,7 +46,7 @@ export const dataWarehouseViewsLogic = kea<dataWarehouseViewsLogicType>([
|
||||
await api.dataWarehouseSavedQueries.delete(viewId)
|
||||
return values.dataWarehouseSavedQueries.filter((view) => view.id !== viewId)
|
||||
},
|
||||
updateDataWarehouseSavedQuery: async (view: DatabaseSchemaViewTable) => {
|
||||
updateDataWarehouseSavedQuery: async (view: Partial<DatabaseSchemaViewTable> & { id: string }) => {
|
||||
const newView = await api.dataWarehouseSavedQueries.update(view.id, view)
|
||||
return values.dataWarehouseSavedQueries.map((savedQuery) => {
|
||||
if (savedQuery.id === view.id) {
|
||||
|
@ -43,7 +43,7 @@ function UpdateSourceConnectionFormContainer(props: UpdateSourceConnectionFormCo
|
||||
<>
|
||||
<span className="block mb-2">Overwrite your existing configuration here</span>
|
||||
<Form logic={dataWarehouseSourceSettingsLogic} formKey="sourceConfig" enableFormOnSubmit>
|
||||
<SourceFormComponent {...props} jobInputs={source?.job_inputs} />
|
||||
<SourceFormComponent {...props} />
|
||||
<div className="mt-4 flex flex-row justify-end gap-2">
|
||||
<LemonButton
|
||||
loading={sourceLoading && !source}
|
||||
|
@ -14,6 +14,7 @@ import { capitalizeFirstLetter } from 'lib/utils'
|
||||
import { experimentsLogic } from 'scenes/experiments/experimentsLogic'
|
||||
|
||||
import { experimentLogic } from './experimentLogic'
|
||||
import { ExperimentsDisabledBanner } from './Experiments'
|
||||
|
||||
const ExperimentFormFields = (): JSX.Element => {
|
||||
const { experiment, featureFlags, groupTypes, aggregationLabel, dynamicFeatureFlagKey } = useValues(experimentLogic)
|
||||
@ -21,7 +22,9 @@ const ExperimentFormFields = (): JSX.Element => {
|
||||
useActions(experimentLogic)
|
||||
const { webExperimentsAvailable } = useValues(experimentsLogic)
|
||||
|
||||
return (
|
||||
return featureFlags[FEATURE_FLAGS.EXPERIMENTS_MIGRATION_DISABLE_UI] ? (
|
||||
<ExperimentsDisabledBanner />
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-6 max-w-120">
|
||||
|
@ -2,11 +2,13 @@ import '../Experiment.scss'
|
||||
|
||||
import { LemonDivider, LemonTabs } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { FEATURE_FLAGS } from 'lib/constants'
|
||||
import { PostHogFeature } from 'posthog-js/react'
|
||||
import { WebExperimentImplementationDetails } from 'scenes/experiments/WebExperimentImplementationDetails'
|
||||
|
||||
import { ExperimentImplementationDetails } from '../ExperimentImplementationDetails'
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { ExperimentsDisabledBanner } from '../Experiments'
|
||||
import {
|
||||
ExperimentLoadingAnimation,
|
||||
LoadingState,
|
||||
@ -67,14 +69,16 @@ const VariantsTab = (): JSX.Element => {
|
||||
}
|
||||
|
||||
export function ExperimentView(): JSX.Element {
|
||||
const { experimentLoading, experimentResultsLoading, experimentId, experimentResults, tabKey } =
|
||||
const { experimentLoading, experimentResultsLoading, experimentId, experimentResults, tabKey, featureFlags } =
|
||||
useValues(experimentLogic)
|
||||
|
||||
const { setTabKey } = useActions(experimentLogic)
|
||||
|
||||
const hasResultsInsight = experimentResults && experimentResults.insight
|
||||
|
||||
return (
|
||||
return featureFlags[FEATURE_FLAGS.EXPERIMENTS_MIGRATION_DISABLE_UI] ? (
|
||||
<ExperimentsDisabledBanner />
|
||||
) : (
|
||||
<>
|
||||
<PageHeaderCustom />
|
||||
<div className="space-y-8 experiment-view">
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { LemonDialog, LemonInput, LemonSelect } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { router } from 'kea-router'
|
||||
import { ExperimentsHog } from 'lib/components/hedgehogs'
|
||||
import { DetectiveHog, ExperimentsHog } from 'lib/components/hedgehogs'
|
||||
import { MemberSelect } from 'lib/components/MemberSelect'
|
||||
import { PageHeader } from 'lib/components/PageHeader'
|
||||
import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction'
|
||||
@ -16,6 +16,7 @@ import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
|
||||
import { LemonTabs } from 'lib/lemon-ui/LemonTabs'
|
||||
import { Link } from 'lib/lemon-ui/Link'
|
||||
import stringWithWBR from 'lib/utils/stringWithWBR'
|
||||
import posthog from 'posthog-js'
|
||||
import { SceneExport } from 'scenes/sceneTypes'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
@ -30,6 +31,33 @@ export const scene: SceneExport = {
|
||||
logic: experimentsLogic,
|
||||
}
|
||||
|
||||
export const ExperimentsDisabledBanner = (): JSX.Element => {
|
||||
const payload = posthog.getFeatureFlagPayload(FEATURE_FLAGS.EXPERIMENTS_MIGRATION_DISABLE_UI)
|
||||
|
||||
return (
|
||||
<div className="border-2 border-dashed border-border w-full p-8 justify-center rounded mt-2 mb-4">
|
||||
<div className="flex items-center gap-8 w-full justify-center flex-wrap">
|
||||
<div>
|
||||
<div className="w-50 mx-auto mb-4">
|
||||
<DetectiveHog className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink max-w-140">
|
||||
<h2>We'll be right back!</h2>
|
||||
<p>
|
||||
We’re upgrading experiments to a new schema to make them faster, more reliable, and ready for
|
||||
future improvements.
|
||||
</p>
|
||||
<p>
|
||||
We expect to be done by <span className="font-semibold">{payload}</span>. Thanks for your
|
||||
patience!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Experiments(): JSX.Element {
|
||||
const {
|
||||
filteredExperiments,
|
||||
@ -189,7 +217,9 @@ export function Experiments(): JSX.Element {
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
return featureFlags[FEATURE_FLAGS.EXPERIMENTS_MIGRATION_DISABLE_UI] ? (
|
||||
<ExperimentsDisabledBanner />
|
||||
) : (
|
||||
<div>
|
||||
<PageHeader
|
||||
buttons={
|
||||
|
@ -164,7 +164,7 @@ export const featureFlagReleaseConditionsLogic = kea<featureFlagReleaseCondition
|
||||
actions.setTotalUsers(response.total_users)
|
||||
},
|
||||
addConditionSet: () => {
|
||||
actions.setAffectedUsers(values.filters.groups.length - 1, -1)
|
||||
actions.setAffectedUsers(values.filters.groups.length - 1, values.totalUsers || -1)
|
||||
},
|
||||
removeConditionSet: ({ index }) => {
|
||||
const previousLength = Object.keys(values.affectedUsers).length
|
||||
@ -183,9 +183,20 @@ export const featureFlagReleaseConditionsLogic = kea<featureFlagReleaseCondition
|
||||
actions.setAffectedUsers(index, undefined)
|
||||
|
||||
const properties = condition.properties
|
||||
if (!properties || properties?.length === 0 || properties.some(isEmptyProperty)) {
|
||||
// don't compute for full rollouts or empty conditions
|
||||
if (!properties || properties.some(isEmptyProperty)) {
|
||||
// don't compute for incomplete conditions
|
||||
usersAffected.push(Promise.resolve({ users_affected: -1, total_users: -1 }))
|
||||
} else if (properties.length === 0) {
|
||||
// Request total users for empty condition sets
|
||||
const responsePromise = api.create(
|
||||
`api/projects/${values.currentTeamId}/feature_flags/user_blast_radius`,
|
||||
{
|
||||
condition: { properties: [] },
|
||||
group_type_index: values.filters?.aggregation_group_type_index ?? null,
|
||||
}
|
||||
)
|
||||
|
||||
usersAffected.push(responsePromise)
|
||||
} else {
|
||||
const responsePromise = api.create(
|
||||
`api/projects/${values.currentTeamId}/feature_flags/user_blast_radius`,
|
||||
|
@ -436,6 +436,7 @@ export function FeatureFlags(): JSX.Element {
|
||||
content: <ActivityLog scope={ActivityScope.FEATURE_FLAG} />,
|
||||
},
|
||||
]}
|
||||
data-attr="feature-flags-tab-navigation"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
@ -128,6 +128,7 @@ describe('the feature flag release conditions logic', () => {
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 140, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 240, total_users: 2002 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 500, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 750, total_users: 2001 }))
|
||||
|
||||
logic.mount()
|
||||
})
|
||||
@ -138,30 +139,44 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: undefined, 2: undefined, 3: undefined },
|
||||
affectedUsers: { 0: 140, 1: undefined, 2: undefined, 3: undefined },
|
||||
totalUsers: null,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: 140 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: 140, 2: 240 },
|
||||
affectedUsers: { 0: 140, 1: 240 },
|
||||
totalUsers: 2002,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: 140, 2: 240, 3: 500 },
|
||||
affectedUsers: { 0: 140, 1: 240, 2: 500 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: 240, 2: 500, 3: 750 },
|
||||
totalUsers: 2001,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates when adding conditions to a flag', async () => {
|
||||
jest.spyOn(api, 'create')
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 140, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 240, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 124, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 248, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 496, total_users: 2000 }))
|
||||
|
||||
logic?.unmount()
|
||||
logic = featureFlagReleaseConditionsLogic({
|
||||
id: '5678',
|
||||
filters: generateFeatureFlagFilters([
|
||||
{
|
||||
properties: [],
|
||||
rollout_percentage: 50,
|
||||
variant: null,
|
||||
},
|
||||
]),
|
||||
})
|
||||
logic.mount()
|
||||
|
||||
await expectLogic(logic, () => {
|
||||
logic.actions.updateConditionSet(0, 20, [
|
||||
@ -176,12 +191,11 @@ describe('the feature flag release conditions logic', () => {
|
||||
// first call is to clear the affected users on mount
|
||||
// second call is to set the affected users for mount logic conditions
|
||||
// third call is to set the affected users for the updateConditionSet action
|
||||
.toDispatchActions(['setAffectedUsers', 'setAffectedUsers', 'setAffectedUsers'])
|
||||
.toDispatchActions(['setAffectedUsers', 'setAffectedUsers', 'setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: undefined },
|
||||
totalUsers: null,
|
||||
affectedUsers: { 0: 124 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toNotHaveDispatchedActions(['setTotalUsers'])
|
||||
|
||||
await expectLogic(logic, () => {
|
||||
logic.actions.updateConditionSet(0, 20, [
|
||||
@ -196,11 +210,11 @@ describe('the feature flag release conditions logic', () => {
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: undefined },
|
||||
totalUsers: null,
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140 },
|
||||
affectedUsers: { 0: 248 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
|
||||
@ -210,7 +224,8 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: -1 },
|
||||
// expect the new empty condition set to initialize affected users to be same as total users
|
||||
affectedUsers: { 0: 248, 1: 2000 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toNotHaveDispatchedActions(['setTotalUsers'])
|
||||
@ -228,7 +243,7 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: undefined },
|
||||
affectedUsers: { 0: 248, 1: undefined },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toNotHaveDispatchedActions(['setTotalUsers'])
|
||||
@ -246,12 +261,12 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: undefined },
|
||||
affectedUsers: { 0: 248, 1: undefined },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: 240 },
|
||||
affectedUsers: { 0: 248, 1: 496 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
|
||||
@ -261,11 +276,11 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 240, 1: 240 },
|
||||
affectedUsers: { 0: 496, 1: 496 },
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 240, 1: undefined },
|
||||
affectedUsers: { 0: 496, 1: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
@ -313,7 +328,6 @@ describe('the feature flag release conditions logic', () => {
|
||||
jest.spyOn(api, 'create')
|
||||
|
||||
logic?.unmount()
|
||||
|
||||
logic = featureFlagReleaseConditionsLogic({
|
||||
id: '12345',
|
||||
filters: generateFeatureFlagFilters([
|
||||
@ -359,11 +373,11 @@ describe('the feature flag release conditions logic', () => {
|
||||
'setTotalUsers',
|
||||
])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: 120, 2: 120 },
|
||||
affectedUsers: { 0: 120, 1: 120, 2: 120 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
|
||||
expect(api.create).toHaveBeenCalledTimes(2)
|
||||
expect(api.create).toHaveBeenCalledTimes(4)
|
||||
|
||||
await expectLogic(logic, () => {
|
||||
logic.actions.updateConditionSet(0, 20, undefined, undefined)
|
||||
@ -378,7 +392,7 @@ describe('the feature flag release conditions logic', () => {
|
||||
}).toNotHaveDispatchedActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
|
||||
// no extra calls when changing rollout percentage
|
||||
expect(api.create).toHaveBeenCalledTimes(2)
|
||||
expect(api.create).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -180,6 +180,10 @@ export const featureFlagsLogic = kea<featureFlagsLogicType>([
|
||||
await breakpoint(300)
|
||||
actions.loadFeatureFlags()
|
||||
},
|
||||
setActiveTab: () => {
|
||||
// Don't carry over pagination from previous tab
|
||||
actions.setFeatureFlagsFilters({ page: 1 }, true)
|
||||
},
|
||||
})),
|
||||
actionToUrl(({ values }) => {
|
||||
const changeUrl = ():
|
||||
@ -231,13 +235,8 @@ export const featureFlagsLogic = kea<featureFlagsLogicType>([
|
||||
order,
|
||||
}
|
||||
|
||||
if (active !== undefined) {
|
||||
pageFiltersFromUrl.active = String(active)
|
||||
}
|
||||
|
||||
if (page !== undefined) {
|
||||
pageFiltersFromUrl.page = parseInt(page)
|
||||
}
|
||||
pageFiltersFromUrl.active = active !== undefined ? String(active) : undefined
|
||||
pageFiltersFromUrl.page = page !== undefined ? parseInt(page) : undefined
|
||||
|
||||
actions.setFeatureFlagsFilters({ ...DEFAULT_FILTERS, ...pageFiltersFromUrl })
|
||||
},
|
||||
|
@ -40,6 +40,7 @@ import {
|
||||
} from 'scenes/trends/mathsLogic'
|
||||
|
||||
import { actionsModel } from '~/models/actionsModel'
|
||||
import { NodeKind } from '~/queries/schema'
|
||||
import { isInsightVizNode, isStickinessQuery } from '~/queries/utils'
|
||||
import {
|
||||
ActionFilter,
|
||||
@ -596,9 +597,20 @@ export function ActionFilterRow({
|
||||
onChange={(properties) => updateFilterProperty({ properties, index })}
|
||||
showNestedArrow={showNestedArrow}
|
||||
disablePopover={!propertyFiltersPopover}
|
||||
metadataSource={
|
||||
filter.type == TaxonomicFilterGroupType.DataWarehouse
|
||||
? {
|
||||
kind: NodeKind.HogQLQuery,
|
||||
query: `select ${filter.distinct_id_field} from ${filter.table_name}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
taxonomicGroupTypes={
|
||||
filter.type == TaxonomicFilterGroupType.DataWarehouse
|
||||
? [TaxonomicFilterGroupType.DataWarehouseProperties]
|
||||
? [
|
||||
TaxonomicFilterGroupType.DataWarehouseProperties,
|
||||
TaxonomicFilterGroupType.HogQLExpression,
|
||||
]
|
||||
: propertiesTaxonomicGroupTypes
|
||||
}
|
||||
eventNames={
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { useValues } from 'kea'
|
||||
import { offset } from '@floating-ui/react'
|
||||
import { LemonButton, Popover } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { HedgehogBuddy } from 'lib/components/HedgehogBuddy/HedgehogBuddy'
|
||||
import { hedgehogBuddyLogic } from 'lib/components/HedgehogBuddy/hedgehogBuddyLogic'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { maxGlobalLogic } from './maxGlobalLogic'
|
||||
import { maxLogic } from './maxLogic'
|
||||
|
||||
const HEADLINES = [
|
||||
@ -14,8 +17,12 @@ const HEADLINES = [
|
||||
|
||||
export function Intro(): JSX.Element {
|
||||
const { hedgehogConfig } = useValues(hedgehogBuddyLogic)
|
||||
const { acceptDataProcessing } = useActions(maxGlobalLogic)
|
||||
const { dataProcessingAccepted } = useValues(maxGlobalLogic)
|
||||
const { sessionId } = useValues(maxLogic)
|
||||
|
||||
const [hedgehogDirection, setHedgehogDirection] = useState<'left' | 'right'>('right')
|
||||
|
||||
const headline = useMemo(() => {
|
||||
return HEADLINES[parseInt(sessionId.split('-').at(-1) as string, 16) % HEADLINES.length]
|
||||
}, [])
|
||||
@ -23,22 +30,52 @@ export function Intro(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="flex">
|
||||
<HedgehogBuddy
|
||||
static
|
||||
hedgehogConfig={{
|
||||
...hedgehogConfig,
|
||||
walking_enabled: false,
|
||||
controls_enabled: false,
|
||||
}}
|
||||
onClick={(actor) => {
|
||||
if (Math.random() < 0.01) {
|
||||
actor.setOnFire()
|
||||
} else {
|
||||
actor.setRandomAnimation()
|
||||
<Popover
|
||||
overlay={
|
||||
<div className="m-1.5">
|
||||
<p className="font-medium text-pretty mb-1.5">
|
||||
Hi! I use OpenAI services to analyze your data,
|
||||
<br />
|
||||
so that you can focus on building. This <em>can</em> include
|
||||
<br />
|
||||
personal data of your users, if you're capturing it.
|
||||
<br />
|
||||
<em>Your data won't be used for training models.</em>
|
||||
</p>
|
||||
<LemonButton type="secondary" size="small" onClick={() => acceptDataProcessing()}>
|
||||
Got it, I accept OpenAI processing data
|
||||
</LemonButton>
|
||||
</div>
|
||||
}
|
||||
placement={`${hedgehogDirection}-end`}
|
||||
middleware={[offset(-12)]}
|
||||
showArrow
|
||||
visible={!dataProcessingAccepted}
|
||||
>
|
||||
<HedgehogBuddy
|
||||
static
|
||||
hedgehogConfig={{
|
||||
...hedgehogConfig,
|
||||
walking_enabled: false,
|
||||
controls_enabled: false,
|
||||
}}
|
||||
onClick={(actor) => {
|
||||
if (Math.random() < 0.01) {
|
||||
actor.setOnFire()
|
||||
} else {
|
||||
actor.setRandomAnimation()
|
||||
}
|
||||
}}
|
||||
onActorLoaded={(actor) =>
|
||||
setTimeout(() => {
|
||||
actor.setAnimation('wave')
|
||||
// Always start out facing right so that the data processing popover is more readable
|
||||
actor.direction = 'right'
|
||||
}, 100)
|
||||
}
|
||||
}}
|
||||
onActorLoaded={(actor) => setTimeout(() => actor.setAnimation('wave'), 100)}
|
||||
/>
|
||||
onPositionChange={(actor) => setHedgehogDirection(actor.direction)}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="text-center mb-3">
|
||||
<h2 className="text-2xl font-bold mb-2 text-balance">{headline}</h2>
|
||||
|
@ -8,6 +8,7 @@ import { mswDecorator, useStorybookMocks } from '~/mocks/browser'
|
||||
|
||||
import { chatResponseChunk, failureChunk, generationFailureChunk } from './__mocks__/chatResponse.mocks'
|
||||
import { MaxInstance } from './Max'
|
||||
import { maxGlobalLogic } from './maxGlobalLogic'
|
||||
import { maxLogic } from './maxLogic'
|
||||
|
||||
const meta: Meta = {
|
||||
@ -31,6 +32,12 @@ export default meta
|
||||
const SESSION_ID = 'b1b4b3b4-1b3b-4b3b-1b3b4b3b4b3b'
|
||||
|
||||
const Template = ({ sessionId: SESSION_ID }: { sessionId: string }): JSX.Element => {
|
||||
const { acceptDataProcessing } = useActions(maxGlobalLogic)
|
||||
|
||||
useEffect(() => {
|
||||
acceptDataProcessing()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-fit">
|
||||
<BindLogic logic={maxLogic} props={{ sessionId: SESSION_ID }}>
|
||||
@ -56,6 +63,11 @@ export const Welcome: StoryFn = () => {
|
||||
],
|
||||
},
|
||||
})
|
||||
const { acceptDataProcessing } = useActions(maxGlobalLogic)
|
||||
useEffect(() => {
|
||||
// We override data processing opt-in to false, so that wee see the welcome screen as a first-time user would
|
||||
acceptDataProcessing(false)
|
||||
}, [])
|
||||
|
||||
return <Template sessionId={SESSION_ID} />
|
||||
}
|
||||
@ -65,7 +77,7 @@ export const WelcomeSuggestionsAvailable: StoryFn = () => {
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentProjectSuccess({ ...MOCK_DEFAULT_PROJECT, product_description: 'A Storybook test.' })
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <Welcome />
|
||||
}
|
||||
@ -81,7 +93,7 @@ export const WelcomeLoadingSuggestions: StoryFn = () => {
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentProjectSuccess({ ...MOCK_DEFAULT_PROJECT, product_description: 'A Storybook test.' })
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <Template sessionId={SESSION_ID} />
|
||||
}
|
||||
|
@ -4,9 +4,11 @@ import clsx from 'clsx'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { maxGlobalLogic } from './maxGlobalLogic'
|
||||
import { maxLogic } from './maxLogic'
|
||||
|
||||
export function QuestionInput(): JSX.Element {
|
||||
const { dataProcessingAccepted } = useValues(maxGlobalLogic)
|
||||
const { question, thread, threadLoading } = useValues(maxLogic)
|
||||
const { askMax, setQuestion } = useActions(maxLogic)
|
||||
|
||||
@ -48,7 +50,15 @@ export function QuestionInput(): JSX.Element {
|
||||
type={isFloating && !question ? 'secondary' : 'primary'}
|
||||
onClick={() => askMax(question)}
|
||||
tooltip="Let's go!"
|
||||
disabledReason={!question ? 'I need some input first' : threadLoading ? 'Thinking…' : undefined}
|
||||
disabledReason={
|
||||
!dataProcessingAccepted
|
||||
? 'Please accept OpenAI processing data'
|
||||
: !question
|
||||
? 'I need some input first'
|
||||
: threadLoading
|
||||
? 'Thinking…'
|
||||
: undefined
|
||||
}
|
||||
size="small"
|
||||
icon={<IconArrowRight />}
|
||||
/>
|
||||
|
@ -4,9 +4,11 @@ import { useActions, useValues } from 'kea'
|
||||
|
||||
import { sidePanelSettingsLogic } from '~/layout/navigation-3000/sidepanel/panels/sidePanelSettingsLogic'
|
||||
|
||||
import { maxGlobalLogic } from './maxGlobalLogic'
|
||||
import { maxLogic } from './maxLogic'
|
||||
|
||||
export function QuestionSuggestions(): JSX.Element {
|
||||
const { dataProcessingAccepted } = useValues(maxGlobalLogic)
|
||||
const { visibleSuggestions, allSuggestionsLoading, currentProject } = useValues(maxLogic)
|
||||
const { askMax, shuffleVisibleSuggestions } = useActions(maxLogic)
|
||||
const { openSettingsPanel } = useActions(sidePanelSettingsLogic)
|
||||
@ -56,6 +58,9 @@ export function QuestionSuggestions(): JSX.Element {
|
||||
sideIcon={<IconArrowUpRight />}
|
||||
center
|
||||
className="shrink"
|
||||
disabledReason={
|
||||
!dataProcessingAccepted ? 'Please accept OpenAI processing data' : undefined
|
||||
}
|
||||
>
|
||||
{suggestion}
|
||||
</LemonButton>
|
||||
|
19
frontend/src/scenes/max/maxGlobalLogic.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { actions, kea, path, reducers } from 'kea'
|
||||
|
||||
import type { maxGlobalLogicType } from './maxGlobalLogicType'
|
||||
|
||||
export const maxGlobalLogic = kea<maxGlobalLogicType>([
|
||||
path(['scenes', 'max', 'maxGlobalLogic']),
|
||||
actions({
|
||||
acceptDataProcessing: (testOnlyOverride?: boolean) => ({ testOnlyOverride }),
|
||||
}),
|
||||
reducers({
|
||||
dataProcessingAccepted: [
|
||||
false,
|
||||
{ persist: true },
|
||||
{
|
||||
acceptDataProcessing: (_, { testOnlyOverride }) => testOnlyOverride ?? true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
@ -99,6 +99,7 @@ export const maxLogic = kea<maxLogicType>([
|
||||
],
|
||||
}),
|
||||
loaders({
|
||||
// TODO: Move question suggestions to `maxGlobalLogic`, which will make this logic `maxThreadLogic`
|
||||
allSuggestions: [
|
||||
null as string[] | null,
|
||||
{
|
||||
|
@ -81,7 +81,7 @@ export const SETTINGS_MAP: SettingSection[] = [
|
||||
description:
|
||||
'Describe your product in a few sentences. This context helps our AI assistant provide relevant answers and suggestions.',
|
||||
component: <ProjectProductDescription />,
|
||||
flag: '!ENVIRONMENTS',
|
||||
flag: ['ARTIFICIAL_HOG', '!ENVIRONMENTS'],
|
||||
},
|
||||
{
|
||||
id: 'snippet',
|
||||
|
@ -74,16 +74,9 @@ export const settingsLogic = kea<settingsLogicType>([
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
(s) => [s.featureFlags],
|
||||
(featureFlags): SettingSection[] => {
|
||||
const sections = SETTINGS_MAP.filter((x) => {
|
||||
const isFlagConditionMet = !x.flag
|
||||
? true // No flag condition
|
||||
: x.flag.startsWith('!')
|
||||
? !featureFlags[FEATURE_FLAGS[x.flag.slice(1)]] // Negated flag condition (!-prefixed)
|
||||
: featureFlags[FEATURE_FLAGS[x.flag]] // Regular flag condition
|
||||
return isFlagConditionMet
|
||||
})
|
||||
(s) => [s.doesMatchFlags, s.featureFlags],
|
||||
(doesMatchFlags, featureFlags): SettingSection[] => {
|
||||
const sections = SETTINGS_MAP.filter(doesMatchFlags)
|
||||
if (!featureFlags[FEATURE_FLAGS.ENVIRONMENTS]) {
|
||||
return sections
|
||||
.filter((section) => section.level !== 'project')
|
||||
@ -108,24 +101,8 @@ export const settingsLogic = kea<settingsLogicType>([
|
||||
},
|
||||
],
|
||||
settings: [
|
||||
(s) => [
|
||||
s.selectedLevel,
|
||||
s.selectedSectionId,
|
||||
s.sections,
|
||||
s.settingId,
|
||||
s.featureFlags,
|
||||
s.hasAvailableFeature,
|
||||
s.preflight,
|
||||
],
|
||||
(
|
||||
selectedLevel,
|
||||
selectedSectionId,
|
||||
sections,
|
||||
settingId,
|
||||
featureFlags,
|
||||
hasAvailableFeature,
|
||||
preflight
|
||||
): Setting[] => {
|
||||
(s) => [s.selectedLevel, s.selectedSectionId, s.sections, s.settingId, s.doesMatchFlags, s.preflight],
|
||||
(selectedLevel, selectedSectionId, sections, settingId, doesMatchFlags, preflight): Setting[] => {
|
||||
let settings: Setting[] = []
|
||||
|
||||
if (selectedSectionId) {
|
||||
@ -140,29 +117,40 @@ export const settingsLogic = kea<settingsLogicType>([
|
||||
return settings.filter((x) => x.id === settingId)
|
||||
}
|
||||
|
||||
return settings
|
||||
.filter((x) => {
|
||||
const isFlagConditionMet = !x.flag
|
||||
? true // No flag condition
|
||||
: x.flag.startsWith('!')
|
||||
? !featureFlags[FEATURE_FLAGS[x.flag.slice(1)]] // Negated flag condition (!-prefixed)
|
||||
: featureFlags[FEATURE_FLAGS[x.flag]] // Regular flag condition
|
||||
if (x.flag && x.features) {
|
||||
return x.features.some((feat) => hasAvailableFeature(feat)) || isFlagConditionMet
|
||||
} else if (x.features) {
|
||||
return x.features.some((feat) => hasAvailableFeature(feat))
|
||||
} else if (x.flag) {
|
||||
return isFlagConditionMet
|
||||
}
|
||||
|
||||
return settings.filter((x) => {
|
||||
if (!doesMatchFlags(x)) {
|
||||
return false
|
||||
}
|
||||
if (x.hideOn?.includes(Realm.Cloud) && preflight?.cloud) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
},
|
||||
],
|
||||
doesMatchFlags: [
|
||||
(s) => [s.featureFlags],
|
||||
(featureFlags) => {
|
||||
return (x: Pick<Setting, 'flag'>) => {
|
||||
if (!x.flag) {
|
||||
// No flag condition
|
||||
return true
|
||||
})
|
||||
.filter((x) => {
|
||||
if (x.hideOn?.includes(Realm.Cloud) && preflight?.cloud) {
|
||||
}
|
||||
const flagsArray = Array.isArray(x.flag) ? x.flag : [x.flag]
|
||||
for (const flagCondition of flagsArray) {
|
||||
const flag = (
|
||||
flagCondition.startsWith('!') ? flagCondition.slice(1) : flagCondition
|
||||
) as keyof typeof FEATURE_FLAGS
|
||||
let isConditionMet = featureFlags[FEATURE_FLAGS[flag]]
|
||||
if (flagCondition.startsWith('!')) {
|
||||
isConditionMet = !isConditionMet // Negated flag condition (!-prefixed)
|
||||
}
|
||||
if (!isConditionMet) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { EitherMembershipLevel, FEATURE_FLAGS } from 'lib/constants'
|
||||
|
||||
import { AvailableFeature, Realm } from '~/types'
|
||||
import { Realm } from '~/types'
|
||||
|
||||
export type SettingsLogicProps = {
|
||||
logicKey?: string
|
||||
@ -111,21 +111,16 @@ export type Setting = {
|
||||
/**
|
||||
* Feature flag to gate the setting being shown.
|
||||
* If prefixed with !, the condition is inverted - the setting will only be shown if the is flag false.
|
||||
* When an array is provided, the setting will be shown if ALL of the conditions are met.
|
||||
*/
|
||||
flag?: FeatureFlagKey | `!${FeatureFlagKey}`
|
||||
features?: AvailableFeature[]
|
||||
flag?: FeatureFlagKey | `!${FeatureFlagKey}` | (FeatureFlagKey | `!${FeatureFlagKey}`)[]
|
||||
hideOn?: Realm[]
|
||||
}
|
||||
|
||||
export type SettingSection = {
|
||||
export interface SettingSection extends Pick<Setting, 'flag'> {
|
||||
id: SettingSectionId
|
||||
title: string
|
||||
level: SettingLevelId
|
||||
settings: Setting[]
|
||||
/**
|
||||
* Feature flag to gate the section being shown.
|
||||
* If prefixed with !, the condition is inverted - the section will only be shown if the is flag false.
|
||||
*/
|
||||
flag?: FeatureFlagKey | `!${FeatureFlagKey}`
|
||||
minimumAccessLevel?: EitherMembershipLevel
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import { IconInfo } from '@posthog/icons'
|
||||
import { IconLock, IconPlus, IconTrash } from '@posthog/icons'
|
||||
import {
|
||||
LemonButton,
|
||||
LemonCalendarSelect,
|
||||
LemonCheckbox,
|
||||
LemonCollapse,
|
||||
LemonDialog,
|
||||
@ -15,17 +16,21 @@ import {
|
||||
LemonTag,
|
||||
LemonTextArea,
|
||||
Link,
|
||||
Popover,
|
||||
} from '@posthog/lemon-ui'
|
||||
import { BindLogic, useActions, useValues } from 'kea'
|
||||
import { EventSelect } from 'lib/components/EventSelect/EventSelect'
|
||||
import { FlagSelector } from 'lib/components/FlagSelector'
|
||||
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
|
||||
import { FEATURE_FLAGS } from 'lib/constants'
|
||||
import { dayjs } from 'lib/dayjs'
|
||||
import { IconCancel } from 'lib/lemon-ui/icons'
|
||||
import { LemonField } from 'lib/lemon-ui/LemonField'
|
||||
import { LemonRadio } from 'lib/lemon-ui/LemonRadio'
|
||||
import { Tooltip } from 'lib/lemon-ui/Tooltip'
|
||||
import { featureFlagLogic as enabledFeaturesLogic } from 'lib/logic/featureFlagLogic'
|
||||
import { formatDate } from 'lib/utils'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { featureFlagLogic } from 'scenes/feature-flags/featureFlagLogic'
|
||||
import { FeatureFlagReleaseConditions } from 'scenes/feature-flags/FeatureFlagReleaseConditions'
|
||||
|
||||
@ -62,15 +67,21 @@ export default function SurveyEdit(): JSX.Element {
|
||||
schedule,
|
||||
hasBranchingLogic,
|
||||
surveyRepeatedActivationAvailable,
|
||||
dataCollectionType,
|
||||
surveyUsesLimit,
|
||||
surveyUsesAdaptiveLimit,
|
||||
} = useValues(surveyLogic)
|
||||
const {
|
||||
setSurveyValue,
|
||||
resetTargeting,
|
||||
resetSurveyResponseLimits,
|
||||
resetSurveyAdaptiveSampling,
|
||||
setSelectedPageIndex,
|
||||
setSelectedSection,
|
||||
setFlagPropertyErrors,
|
||||
setSchedule,
|
||||
deleteBranchingLogic,
|
||||
setDataCollectionType,
|
||||
} = useActions(surveyLogic)
|
||||
const {
|
||||
surveysMultipleQuestionsAvailable,
|
||||
@ -79,11 +90,25 @@ export default function SurveyEdit(): JSX.Element {
|
||||
surveysActionsAvailable,
|
||||
} = useValues(surveysLogic)
|
||||
const { featureFlags } = useValues(enabledFeaturesLogic)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const sortedItemIds = survey.questions.map((_, idx) => idx.toString())
|
||||
const { thankYouMessageDescriptionContentType = null } = survey.appearance ?? {}
|
||||
const surveysRecurringScheduleDisabledReason = surveysRecurringScheduleAvailable
|
||||
? undefined
|
||||
: 'Upgrade your plan to use repeating surveys'
|
||||
const surveysAdaptiveLimitsDisabledReason = surveysRecurringScheduleAvailable
|
||||
? undefined
|
||||
: 'Upgrade your plan to use an adaptive limit on survey responses'
|
||||
|
||||
useMemo(() => {
|
||||
if (surveyUsesLimit) {
|
||||
setDataCollectionType('until_limit')
|
||||
} else if (surveyUsesAdaptiveLimit) {
|
||||
setDataCollectionType('until_adaptive_limit')
|
||||
} else {
|
||||
setDataCollectionType('until_stopped')
|
||||
}
|
||||
}, [surveyUsesLimit, surveyUsesAdaptiveLimit, setDataCollectionType])
|
||||
|
||||
if (survey.iteration_count && survey.iteration_count > 0) {
|
||||
setSchedule('recurring')
|
||||
@ -852,44 +877,157 @@ export default function SurveyEdit(): JSX.Element {
|
||||
header: 'Completion conditions',
|
||||
content: (
|
||||
<>
|
||||
<LemonField name="responses_limit">
|
||||
{({ onChange, value }) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<LemonCheckbox
|
||||
checked={!!value}
|
||||
onChange={(checked) => {
|
||||
const newResponsesLimit = checked ? 100 : null
|
||||
onChange(newResponsesLimit)
|
||||
}}
|
||||
/>
|
||||
Stop the survey once
|
||||
<LemonInput
|
||||
type="number"
|
||||
data-attr="survey-responses-limit-input"
|
||||
size="small"
|
||||
min={1}
|
||||
value={value || NaN}
|
||||
onChange={(newValue) => {
|
||||
if (newValue && newValue > 0) {
|
||||
onChange(newValue)
|
||||
} else {
|
||||
onChange(null)
|
||||
}
|
||||
}}
|
||||
className="w-16"
|
||||
/>{' '}
|
||||
responses are received.
|
||||
<Tooltip title="This is a rough guideline, not an absolute one, so the survey might receive slightly more responses than the limit specifies.">
|
||||
<IconInfo />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</LemonField>
|
||||
<div className="mt-2">
|
||||
<h3> How long would you like to collect survey responses? </h3>
|
||||
<LemonField.Pure>
|
||||
<LemonRadio
|
||||
value={dataCollectionType}
|
||||
onChange={(
|
||||
newValue: 'until_stopped' | 'until_limit' | 'until_adaptive_limit'
|
||||
) => {
|
||||
if (newValue === 'until_limit') {
|
||||
resetSurveyAdaptiveSampling()
|
||||
setSurveyValue('responses_limit', survey.responses_limit || 100)
|
||||
} else if (newValue === 'until_adaptive_limit') {
|
||||
resetSurveyResponseLimits()
|
||||
setSurveyValue(
|
||||
'response_sampling_interval',
|
||||
survey.response_sampling_interval || 1
|
||||
)
|
||||
setSurveyValue(
|
||||
'response_sampling_interval_type',
|
||||
survey.response_sampling_interval_type || 'month'
|
||||
)
|
||||
setSurveyValue(
|
||||
'response_sampling_limit',
|
||||
survey.response_sampling_limit || 100
|
||||
)
|
||||
setSurveyValue(
|
||||
'response_sampling_start_date',
|
||||
survey.response_sampling_start_date || dayjs()
|
||||
)
|
||||
} else {
|
||||
resetSurveyResponseLimits()
|
||||
resetSurveyAdaptiveSampling()
|
||||
}
|
||||
setDataCollectionType(newValue)
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: 'until_stopped',
|
||||
label: 'Keep collecting responses until the survey is stopped',
|
||||
'data-attr': 'survey-collection-until-stopped',
|
||||
},
|
||||
{
|
||||
value: 'until_limit',
|
||||
label: 'Stop displaying the survey after reaching a certain number of completed surveys',
|
||||
'data-attr': 'survey-collection-until-limit',
|
||||
},
|
||||
{
|
||||
value: 'until_adaptive_limit',
|
||||
label: 'Collect a certain number of surveys per day, week or month',
|
||||
'data-attr': 'survey-collection-until-adaptive-limit',
|
||||
disabledReason: surveysAdaptiveLimitsDisabledReason,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</LemonField.Pure>
|
||||
</div>
|
||||
{dataCollectionType == 'until_adaptive_limit' && (
|
||||
<LemonField.Pure className="mt-4">
|
||||
<div className="flex flex-row gap-2 items-center ml-5">
|
||||
Starting on{' '}
|
||||
<Popover
|
||||
actionable
|
||||
overlay={
|
||||
<LemonCalendarSelect
|
||||
value={dayjs(survey.response_sampling_start_date)}
|
||||
onChange={(value) => {
|
||||
setSurveyValue('response_sampling_start_date', value)
|
||||
setVisible(false)
|
||||
}}
|
||||
showTimeToggle={false}
|
||||
onClose={() => setVisible(false)}
|
||||
/>
|
||||
}
|
||||
visible={visible}
|
||||
onClickOutside={() => setVisible(false)}
|
||||
>
|
||||
<LemonButton type="secondary" onClick={() => setVisible(!visible)}>
|
||||
{formatDate(dayjs(survey.response_sampling_start_date || ''))}
|
||||
</LemonButton>
|
||||
</Popover>
|
||||
, capture up to
|
||||
<LemonInput
|
||||
type="number"
|
||||
size="small"
|
||||
min={1}
|
||||
onChange={(newValue) => {
|
||||
setSurveyValue('response_sampling_limit', newValue)
|
||||
}}
|
||||
value={survey.response_sampling_limit || 0}
|
||||
/>
|
||||
responses, every
|
||||
<LemonInput
|
||||
type="number"
|
||||
size="small"
|
||||
min={1}
|
||||
onChange={(newValue) => {
|
||||
setSurveyValue('response_sampling_interval', newValue)
|
||||
}}
|
||||
value={survey.response_sampling_interval || 0}
|
||||
/>
|
||||
<LemonSelect
|
||||
value={survey.response_sampling_interval_type}
|
||||
size="small"
|
||||
onChange={(newValue) => {
|
||||
setSurveyValue('response_sampling_interval_type', newValue)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'day', label: 'Day(s)' },
|
||||
{ value: 'week', label: 'Week(s)' },
|
||||
{ value: 'month', label: 'Month(s)' },
|
||||
]}
|
||||
/>
|
||||
<Tooltip title="This is a rough guideline, not an absolute one, so the survey might receive slightly more responses than the limit specifies.">
|
||||
<IconInfo />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</LemonField.Pure>
|
||||
)}
|
||||
{dataCollectionType == 'until_limit' && (
|
||||
<LemonField name="responses_limit" className="mt-4 ml-5">
|
||||
{({ onChange, value }) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
Stop the survey once
|
||||
<LemonInput
|
||||
type="number"
|
||||
data-attr="survey-responses-limit-input"
|
||||
size="small"
|
||||
min={1}
|
||||
value={value || NaN}
|
||||
onChange={(newValue) => {
|
||||
if (newValue && newValue > 0) {
|
||||
onChange(newValue)
|
||||
} else {
|
||||
onChange(null)
|
||||
}
|
||||
}}
|
||||
className="w-16"
|
||||
/>{' '}
|
||||
responses are received.
|
||||
<Tooltip title="This is a rough guideline, not an absolute one, so the survey might receive slightly more responses than the limit specifies.">
|
||||
<IconInfo />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</LemonField>
|
||||
)}
|
||||
{featureFlags[FEATURE_FLAGS.SURVEYS_RECURRING] && (
|
||||
<div className="mt-2">
|
||||
<h4> How often should we show this survey? </h4>
|
||||
<div className="mt-4">
|
||||
<h3> How often should we show this survey? </h3>
|
||||
<LemonField.Pure>
|
||||
<LemonRadio
|
||||
value={schedule}
|
||||
|
@ -47,6 +47,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
|
||||
setSelectedPageIndex,
|
||||
duplicateSurvey,
|
||||
} = useActions(surveyLogic)
|
||||
const { surveyUsesLimit, surveyUsesAdaptiveLimit } = useValues(surveyLogic)
|
||||
const { deleteSurvey } = useActions(surveysLogic)
|
||||
|
||||
const [tabKey, setTabKey] = useState(survey.start_date ? 'results' : 'overview')
|
||||
@ -342,7 +343,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{survey.responses_limit && (
|
||||
{surveyUsesLimit && (
|
||||
<>
|
||||
<span className="card-secondary mt-4">Completion conditions</span>
|
||||
<span>
|
||||
@ -351,6 +352,17 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{surveyUsesAdaptiveLimit && (
|
||||
<>
|
||||
<span className="card-secondary mt-4">Completion conditions</span>
|
||||
<span>
|
||||
Survey response collection is limited to receive{' '}
|
||||
<b>{survey.response_sampling_limit}</b> responses every{' '}
|
||||
{survey.response_sampling_interval}{' '}
|
||||
{survey.response_sampling_interval_type}(s).
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<LemonDivider />
|
||||
<SurveyDisplaySummary
|
||||
id={id}
|
||||
|
@ -142,6 +142,10 @@ export interface NewSurvey
|
||||
| 'iteration_frequency_days'
|
||||
| 'iteration_start_dates'
|
||||
| 'current_iteration'
|
||||
| 'response_sampling_start_date'
|
||||
| 'response_sampling_interval_type'
|
||||
| 'response_sampling_interval'
|
||||
| 'response_sampling_limit'
|
||||
> {
|
||||
id: 'new'
|
||||
linked_flag_id: number | null
|
||||
|
@ -105,6 +105,7 @@ export interface QuestionResultsReady {
|
||||
[key: string]: boolean
|
||||
}
|
||||
|
||||
export type DataCollectionType = 'until_stopped' | 'until_limit' | 'until_adaptive_limit'
|
||||
export type ScheduleType = 'once' | 'recurring'
|
||||
|
||||
const getResponseField = (i: number): string => (i === 0 ? '$survey_response' : `$survey_response_${i}`)
|
||||
@ -168,6 +169,9 @@ export const surveyLogic = kea<surveyLogicType>([
|
||||
nextStep,
|
||||
specificQuestionIndex,
|
||||
}),
|
||||
setDataCollectionType: (dataCollectionType: DataCollectionType) => ({
|
||||
dataCollectionType,
|
||||
}),
|
||||
resetBranchingForQuestion: (questionIndex) => ({ questionIndex }),
|
||||
deleteBranchingLogic: true,
|
||||
archiveSurvey: true,
|
||||
@ -178,6 +182,8 @@ export const surveyLogic = kea<surveyLogicType>([
|
||||
|
||||
setSchedule: (schedule: ScheduleType) => ({ schedule }),
|
||||
resetTargeting: true,
|
||||
resetSurveyAdaptiveSampling: true,
|
||||
resetSurveyResponseLimits: true,
|
||||
setFlagPropertyErrors: (errors: any) => ({ errors }),
|
||||
}),
|
||||
loaders(({ props, actions, values }) => ({
|
||||
@ -608,6 +614,19 @@ export const surveyLogic = kea<surveyLogicType>([
|
||||
loadSurveySuccess: () => {
|
||||
actions.loadSurveyUserStats()
|
||||
},
|
||||
resetSurveyResponseLimits: () => {
|
||||
actions.setSurveyValue('responses_limit', null)
|
||||
},
|
||||
|
||||
resetSurveyAdaptiveSampling: () => {
|
||||
actions.setSurveyValues({
|
||||
response_sampling_interval: null,
|
||||
response_sampling_interval_type: null,
|
||||
response_sampling_limit: null,
|
||||
response_sampling_start_date: null,
|
||||
response_sampling_daily_limits: null,
|
||||
})
|
||||
},
|
||||
resetTargeting: () => {
|
||||
actions.setSurveyValue('linked_flag_id', NEW_SURVEY.linked_flag_id)
|
||||
actions.setSurveyValue('targeting_flag_filters', NEW_SURVEY.targeting_flag_filters)
|
||||
@ -647,6 +666,12 @@ export const surveyLogic = kea<surveyLogicType>([
|
||||
setSurveyMissing: () => true,
|
||||
},
|
||||
],
|
||||
dataCollectionType: [
|
||||
'until_stopped' as DataCollectionType,
|
||||
{
|
||||
setDataCollectionType: (_, { dataCollectionType }) => dataCollectionType,
|
||||
},
|
||||
],
|
||||
|
||||
survey: [
|
||||
{ ...NEW_SURVEY } as NewSurvey | Survey,
|
||||
@ -877,6 +902,24 @@ export const surveyLogic = kea<surveyLogicType>([
|
||||
return !!(survey.start_date && !survey.end_date)
|
||||
},
|
||||
],
|
||||
surveyUsesLimit: [
|
||||
(s) => [s.survey],
|
||||
(survey: Survey): boolean => {
|
||||
return !!(survey.responses_limit && survey.responses_limit > 0)
|
||||
},
|
||||
],
|
||||
surveyUsesAdaptiveLimit: [
|
||||
(s) => [s.survey],
|
||||
(survey: Survey): boolean => {
|
||||
return !!(
|
||||
survey.response_sampling_interval &&
|
||||
survey.response_sampling_interval > 0 &&
|
||||
survey.response_sampling_interval_type !== '' &&
|
||||
survey.response_sampling_limit &&
|
||||
survey.response_sampling_limit > 0
|
||||
)
|
||||
},
|
||||
],
|
||||
surveyShufflingQuestionsAvailable: [
|
||||
(s) => [s.survey],
|
||||
(survey: Survey): boolean => {
|
||||
@ -1022,6 +1065,7 @@ export const surveyLogic = kea<surveyLogicType>([
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
getBranchingDropdownValue: [
|
||||
(s) => [s.survey],
|
||||
(survey) => (questionIndex: number, question: RatingSurveyQuestion | MultipleSurveyQuestion) => {
|
||||
|
@ -2767,6 +2767,11 @@ export interface Survey {
|
||||
iteration_start_dates?: string[]
|
||||
current_iteration?: number | null
|
||||
current_iteration_start_date?: string
|
||||
response_sampling_start_date?: string | null
|
||||
response_sampling_interval_type?: string | null
|
||||
response_sampling_interval?: number | null
|
||||
response_sampling_limit?: number | null
|
||||
response_sampling_daily_limits?: string[] | null
|
||||
}
|
||||
|
||||
export enum SurveyUrlMatchType {
|
||||
|
@ -1,4 +1,5 @@
|
||||
["_H", 1, 32, "this is a secure string", 32, "string:", 36, 0, 2, "print", 2, 35, 32, "md5Hex(string):", 36, 0, 2,
|
||||
"md5Hex", 1, 2, "print", 2, 35, 32, "sha256Hex(string):", 36, 0, 2, "sha256Hex", 1, 2, "print", 2, 35, 32, "1", 32,
|
||||
"string", 32, "more", 32, "keys", 43, 4, 32, "data:", 36, 1, 2, "print", 2, 35, 32, "sha256HmacChainHex(data):", 36, 1,
|
||||
2, "sha256HmacChainHex", 1, 2, "print", 2, 35, 35, 35]
|
||||
"md5Hex", 1, 2, "print", 2, 35, 32, "md5Hex(null):", 31, 2, "md5Hex", 1, 2, "print", 2, 35, 32, "sha256Hex(string):",
|
||||
36, 0, 2, "sha256Hex", 1, 2, "print", 2, 35, 32, "sha256Hex(null):", 31, 2, "sha256Hex", 1, 2, "print", 2, 35, 32, "1",
|
||||
32, "string", 32, "more", 32, "keys", 43, 4, 32, "data:", 36, 1, 2, "print", 2, 35, 32, "sha256HmacChainHex(data):", 36,
|
||||
1, 2, "sha256HmacChainHex", 1, 2, "print", 2, 35, 35, 35]
|
||||
|
@ -1,5 +1,7 @@
|
||||
string: this is a secure string
|
||||
md5Hex(string): e7b466647ea215dbe59b00c756560911
|
||||
md5Hex(null): null
|
||||
sha256Hex(string): 5216c0931310b31737ef30353830c234901283544e934f54eb75f622cfb86c9d
|
||||
sha256Hex(null): null
|
||||
data: ['1', 'string', 'more', 'keys']
|
||||
sha256HmacChainHex(data): 826820d7eeca97f26ca18096be85fed346f6fd9cc18d64e72c935bea3450dbd9
|
||||
|
@ -1,7 +1,9 @@
|
||||
let string := 'this is a secure string'
|
||||
print('string:', string)
|
||||
print('md5Hex(string):', md5Hex(string))
|
||||
print('md5Hex(null):', md5Hex(null))
|
||||
print('sha256Hex(string):', sha256Hex(string))
|
||||
print('sha256Hex(null):', sha256Hex(null))
|
||||
|
||||
let data := ['1', 'string', 'more', 'keys']
|
||||
print('data:', data)
|
||||
|
@ -2,11 +2,15 @@ import hashlib
|
||||
import hmac
|
||||
|
||||
|
||||
def md5Hex(data: str) -> str:
|
||||
def md5Hex(data: str | None) -> str | None:
|
||||
if data is None:
|
||||
return None
|
||||
return hashlib.md5(data.encode()).hexdigest()
|
||||
|
||||
|
||||
def sha256Hex(data: str) -> str:
|
||||
def sha256Hex(data: str | None) -> str | None:
|
||||
if data is None:
|
||||
return None
|
||||
return hashlib.sha256(data.encode()).hexdigest()
|
||||
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@posthog/hogvm",
|
||||
"version": "1.0.59",
|
||||
"version": "1.0.60",
|
||||
"description": "PostHog Hog Virtual Machine",
|
||||
"types": "dist/index.d.ts",
|
||||
"source": "src/index.ts",
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ExecOptions } from '../types'
|
||||
|
||||
export function sha256Hex(data: string, options?: ExecOptions): string {
|
||||
export function sha256Hex(data: string | null, options?: ExecOptions): string | null {
|
||||
if (data === null) { return null }
|
||||
const crypto = options?.external?.crypto
|
||||
if (!crypto) {
|
||||
throw new Error('The crypto module is required for "sha256Hex" to work.')
|
||||
@ -8,7 +9,8 @@ export function sha256Hex(data: string, options?: ExecOptions): string {
|
||||
return crypto.createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
export function md5Hex(data: string, options?: ExecOptions): string {
|
||||
export function md5Hex(data: string | null, options?: ExecOptions): string | null {
|
||||
if (data === null) { return null }
|
||||
const crypto = options?.external?.crypto
|
||||
if (!crypto) {
|
||||
throw new Error('The crypto module is required for "md5Hex" to work.')
|
||||
|
@ -76,7 +76,7 @@
|
||||
"@medv/finder": "^3.1.0",
|
||||
"@microlink/react-json-view": "^1.21.3",
|
||||
"@monaco-editor/react": "4.6.0",
|
||||
"@posthog/hogvm": "^1.0.59",
|
||||
"@posthog/hogvm": "^1.0.60",
|
||||
"@posthog/icons": "0.9.1",
|
||||
"@posthog/plugin-scaffold": "^1.4.4",
|
||||
"@react-hook/size": "^2.1.2",
|
||||
@ -154,7 +154,7 @@
|
||||
"pmtiles": "^2.11.0",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-preset-env": "^9.3.0",
|
||||
"posthog-js": "1.186.2",
|
||||
"posthog-js": "1.187.2",
|
||||
"posthog-js-lite": "3.0.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prop-types": "^15.7.2",
|
||||
|
@ -54,7 +54,7 @@
|
||||
"@maxmind/geoip2-node": "^3.4.0",
|
||||
"@posthog/clickhouse": "^1.7.0",
|
||||
"@posthog/cyclotron": "file:../rust/cyclotron-node",
|
||||
"@posthog/hogvm": "^1.0.59",
|
||||
"@posthog/hogvm": "^1.0.60",
|
||||
"@posthog/plugin-scaffold": "1.4.4",
|
||||
"@sentry/node": "^7.49.0",
|
||||
"@sentry/profiling-node": "^0.3.0",
|
||||
|
@ -47,8 +47,8 @@ dependencies:
|
||||
specifier: file:../rust/cyclotron-node
|
||||
version: file:../rust/cyclotron-node
|
||||
'@posthog/hogvm':
|
||||
specifier: ^1.0.59
|
||||
version: 1.0.59(luxon@3.4.4)
|
||||
specifier: ^1.0.60
|
||||
version: 1.0.60(luxon@3.4.4)
|
||||
'@posthog/plugin-scaffold':
|
||||
specifier: 1.4.4
|
||||
version: 1.4.4
|
||||
@ -3119,8 +3119,8 @@ packages:
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/@posthog/hogvm@1.0.59(luxon@3.4.4):
|
||||
resolution: {integrity: sha512-4KJfCXUhK7x5Wm3pheKWDmrbQ0y1lWlLWdVEjocdjSy3wOS8hQQqaFAVEKZs7hfk9pZqvNFh2UPgD4ccpwUQjA==}
|
||||
/@posthog/hogvm@1.0.60(luxon@3.4.4):
|
||||
resolution: {integrity: sha512-W0FTorn5FqIaNQCMTXbNi1dJSphe/UEztDTXIhwsWLNsSO7haF3xx8JSp7vowo6R432ExjPPoIFT1gtRVV17kQ==}
|
||||
peerDependencies:
|
||||
luxon: ^3.4.4
|
||||
dependencies:
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Person, PreIngestionEvent, RawClickHouseEvent } from '../../../types'
|
||||
import { Person, PreIngestionEvent, RawKafkaEvent } from '../../../types'
|
||||
import { EventPipelineRunner } from './runner'
|
||||
|
||||
export function createEventStep(
|
||||
@ -6,6 +6,6 @@ export function createEventStep(
|
||||
event: PreIngestionEvent,
|
||||
person: Person,
|
||||
processPerson: boolean
|
||||
): [RawClickHouseEvent, Promise<void>] {
|
||||
): RawKafkaEvent {
|
||||
return runner.eventsProcessor.createEvent(event, person, processPerson)
|
||||
}
|
||||
|
@ -0,0 +1,6 @@
|
||||
import { RawKafkaEvent } from '../../../types'
|
||||
import { EventPipelineRunner } from './runner'
|
||||
|
||||
export function emitEventStep(runner: EventPipelineRunner, event: RawKafkaEvent): [Promise<void>] {
|
||||
return [runner.eventsProcessor.emitEvent(event)]
|
||||
}
|
@ -1,83 +0,0 @@
|
||||
import { captureException } from '@sentry/node'
|
||||
import { Counter } from 'prom-client'
|
||||
|
||||
import { PreIngestionEvent } from '../../../types'
|
||||
import { EventPipelineRunner } from './runner'
|
||||
|
||||
const EXTERNAL_FINGERPRINT_COUNTER = new Counter({
|
||||
name: 'enrich_exception_events_external_fingerprint',
|
||||
help: 'Counter for exceptions that already have a fingerprint',
|
||||
})
|
||||
|
||||
const COULD_NOT_PARSE_STACK_TRACE_COUNTER = new Counter({
|
||||
name: 'enrich_exception_events_could_not_parse_stack_trace',
|
||||
help: 'Counter for exceptions where the stack trace could not be parsed',
|
||||
})
|
||||
|
||||
const COULD_NOT_PREPARE_FOR_FINGERPRINTING_COUNTER = new Counter({
|
||||
name: 'enrich_exception_events_could_not_prepare_for_fingerprinting',
|
||||
help: 'Counter for exceptions where the event could not be prepared for fingerprinting',
|
||||
})
|
||||
|
||||
const EXCEPTIONS_ENRICHED_COUNTER = new Counter({
|
||||
name: 'enrich_exception_events_enriched',
|
||||
help: 'Counter for exceptions that have been enriched',
|
||||
})
|
||||
|
||||
export function enrichExceptionEventStep(
|
||||
_runner: EventPipelineRunner,
|
||||
event: PreIngestionEvent
|
||||
): Promise<PreIngestionEvent> {
|
||||
if (event.event !== '$exception') {
|
||||
return Promise.resolve(event)
|
||||
}
|
||||
|
||||
let type: string | null = null
|
||||
let message: string | null = null
|
||||
let firstFunction: string | null = null
|
||||
let exceptionList: any[] | null = null
|
||||
|
||||
try {
|
||||
exceptionList = event.properties['$exception_list']
|
||||
const fingerPrint = event.properties['$exception_fingerprint']
|
||||
type = event.properties['$exception_type']
|
||||
message = event.properties['$exception_message']
|
||||
|
||||
if (!type && exceptionList && exceptionList.length > 0) {
|
||||
type = exceptionList[0].type
|
||||
}
|
||||
if (!message && exceptionList && exceptionList.length > 0) {
|
||||
message = exceptionList[0].value
|
||||
}
|
||||
|
||||
if (fingerPrint) {
|
||||
EXTERNAL_FINGERPRINT_COUNTER.inc()
|
||||
return Promise.resolve(event)
|
||||
}
|
||||
} catch (e) {
|
||||
captureException(e)
|
||||
COULD_NOT_PREPARE_FOR_FINGERPRINTING_COUNTER.inc()
|
||||
}
|
||||
|
||||
try {
|
||||
if (exceptionList && exceptionList.length > 0) {
|
||||
const firstException = exceptionList[0]
|
||||
if (firstException.stacktrace) {
|
||||
// TODO: Should this be the last function instead?, or first in app function?
|
||||
firstFunction = firstException.stacktrace.frames[0].function
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
captureException(e)
|
||||
COULD_NOT_PARSE_STACK_TRACE_COUNTER.inc()
|
||||
}
|
||||
|
||||
const fingerprint = [type, message, firstFunction].filter(Boolean)
|
||||
event.properties['$exception_fingerprint'] = fingerprint.length ? fingerprint : undefined
|
||||
|
||||
if (event.properties['$exception_fingerprint']) {
|
||||
EXCEPTIONS_ENRICHED_COUNTER.inc()
|
||||
}
|
||||
|
||||
return Promise.resolve(event)
|
||||
}
|
@ -1,15 +1,15 @@
|
||||
import { RawClickHouseEvent } from '../../../types'
|
||||
import { RawKafkaEvent } from '../../../types'
|
||||
import { status } from '../../../utils/status'
|
||||
import { EventPipelineRunner } from './runner'
|
||||
|
||||
export function produceExceptionSymbolificationEventStep(
|
||||
runner: EventPipelineRunner,
|
||||
event: RawClickHouseEvent
|
||||
event: RawKafkaEvent
|
||||
): Promise<[Promise<void>]> {
|
||||
const ack = runner.hub.kafkaProducer
|
||||
.produce({
|
||||
topic: runner.hub.EXCEPTIONS_SYMBOLIFICATION_KAFKA_TOPIC,
|
||||
key: event.uuid,
|
||||
key: String(event.team_id),
|
||||
value: Buffer.from(JSON.stringify(event)),
|
||||
waitForAck: true,
|
||||
})
|
||||
|
@ -11,7 +11,7 @@ import { status } from '../../../utils/status'
|
||||
import { EventsProcessor } from '../process-event'
|
||||
import { captureIngestionWarning, generateEventDeadLetterQueueMessage } from '../utils'
|
||||
import { createEventStep } from './createEventStep'
|
||||
import { enrichExceptionEventStep } from './enrichExceptionEventStep'
|
||||
import { emitEventStep } from './emitEventStep'
|
||||
import { extractHeatmapDataStep } from './extractHeatmapDataStep'
|
||||
import {
|
||||
eventProcessedAndIngestedCounter,
|
||||
@ -253,30 +253,25 @@ export class EventPipelineRunner {
|
||||
heatmapKafkaAcks.forEach((ack) => kafkaAcks.push(ack))
|
||||
}
|
||||
|
||||
const enrichedIfErrorEvent = await this.runStep(
|
||||
enrichExceptionEventStep,
|
||||
[this, preparedEventWithoutHeatmaps],
|
||||
event.team_id
|
||||
)
|
||||
|
||||
const [rawClickhouseEvent, eventAck] = await this.runStep(
|
||||
const rawEvent = await this.runStep(
|
||||
createEventStep,
|
||||
[this, enrichedIfErrorEvent, person, processPerson],
|
||||
[this, preparedEventWithoutHeatmaps, person, processPerson],
|
||||
event.team_id
|
||||
)
|
||||
kafkaAcks.push(eventAck)
|
||||
|
||||
if (event.event === '$exception' && event.team_id == 2) {
|
||||
if (event.event === '$exception' && !event.properties?.hasOwnProperty('$sentry_event_id')) {
|
||||
const [exceptionAck] = await this.runStep(
|
||||
produceExceptionSymbolificationEventStep,
|
||||
[this, rawClickhouseEvent],
|
||||
[this, rawEvent],
|
||||
event.team_id
|
||||
)
|
||||
kafkaAcks.push(exceptionAck)
|
||||
return this.registerLastStep('produceExceptionSymbolificationEventStep', [rawClickhouseEvent], kafkaAcks)
|
||||
return this.registerLastStep('produceExceptionSymbolificationEventStep', [rawEvent], kafkaAcks)
|
||||
} else {
|
||||
const [clickhouseAck] = await this.runStep(emitEventStep, [this, rawEvent], event.team_id)
|
||||
kafkaAcks.push(clickhouseAck)
|
||||
return this.registerLastStep('emitEventStep', [rawEvent], kafkaAcks)
|
||||
}
|
||||
|
||||
return this.registerLastStep('createEventStep', [rawClickhouseEvent], kafkaAcks)
|
||||
}
|
||||
|
||||
registerLastStep(stepName: string, args: any[], ackPromises?: Array<Promise<void>>): EventPipelineResult {
|
||||
|
@ -204,11 +204,7 @@ export class EventsProcessor {
|
||||
return res
|
||||
}
|
||||
|
||||
createEvent(
|
||||
preIngestionEvent: PreIngestionEvent,
|
||||
person: Person,
|
||||
processPerson: boolean
|
||||
): [RawKafkaEvent, Promise<void>] {
|
||||
createEvent(preIngestionEvent: PreIngestionEvent, person: Person, processPerson: boolean): RawKafkaEvent {
|
||||
const { eventUuid: uuid, event, teamId, projectId, distinctId, properties, timestamp } = preIngestionEvent
|
||||
|
||||
let elementsChain = ''
|
||||
@ -264,10 +260,14 @@ export class EventsProcessor {
|
||||
person_mode: personMode,
|
||||
}
|
||||
|
||||
return rawEvent
|
||||
}
|
||||
|
||||
emitEvent(rawEvent: RawKafkaEvent): Promise<void> {
|
||||
const ack = this.kafkaProducer
|
||||
.produce({
|
||||
topic: this.pluginsServer.CLICKHOUSE_JSON_EVENTS_KAFKA_TOPIC,
|
||||
key: uuid,
|
||||
key: rawEvent.uuid,
|
||||
value: Buffer.from(JSON.stringify(rawEvent)),
|
||||
waitForAck: true,
|
||||
})
|
||||
@ -275,16 +275,16 @@ export class EventsProcessor {
|
||||
// Some messages end up significantly larger than the original
|
||||
// after plugin processing, person & group enrichment, etc.
|
||||
if (error instanceof MessageSizeTooLarge) {
|
||||
await captureIngestionWarning(this.db.kafkaProducer, teamId, 'message_size_too_large', {
|
||||
eventUuid: uuid,
|
||||
distinctId: distinctId,
|
||||
await captureIngestionWarning(this.db.kafkaProducer, rawEvent.team_id, 'message_size_too_large', {
|
||||
eventUuid: rawEvent.uuid,
|
||||
distinctId: rawEvent.distinct_id,
|
||||
})
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
return [rawEvent, ack]
|
||||
return ack
|
||||
}
|
||||
|
||||
private async upsertGroup(
|
||||
|
@ -96,22 +96,6 @@ Array [
|
||||
},
|
||||
],
|
||||
],
|
||||
Array [
|
||||
"enrichExceptionEventStep",
|
||||
Array [
|
||||
Object {
|
||||
"distinctId": "my_id",
|
||||
"elementsList": Array [],
|
||||
"event": "$pageview",
|
||||
"eventUuid": "uuid1",
|
||||
"ip": "127.0.0.1",
|
||||
"projectId": 1,
|
||||
"properties": Object {},
|
||||
"teamId": 2,
|
||||
"timestamp": "2020-02-23T02:15:00.000Z",
|
||||
},
|
||||
],
|
||||
],
|
||||
Array [
|
||||
"createEventStep",
|
||||
Array [
|
||||
@ -144,5 +128,24 @@ Array [
|
||||
true,
|
||||
],
|
||||
],
|
||||
Array [
|
||||
"emitEventStep",
|
||||
Array [
|
||||
Object {
|
||||
"created_at": "2024-11-18 14:54:33.606",
|
||||
"distinct_id": "my_id",
|
||||
"elements_chain": "",
|
||||
"event": "$pageview",
|
||||
"person_created_at": "2024-11-18 14:54:33",
|
||||
"person_mode": "full",
|
||||
"person_properties": "{}",
|
||||
"project_id": 1,
|
||||
"properties": "{}",
|
||||
"team_id": 2,
|
||||
"timestamp": "2020-02-23 02:15:00.000",
|
||||
"uuid": "uuid1",
|
||||
},
|
||||
],
|
||||
],
|
||||
]
|
||||
`;
|
||||
|