- {closable && (
+ {closable && !hideCloseButton && (
// The key causes the div to be re-rendered, which restarts the animation,
// providing immediate visual feedback on click
) : (
+ // eslint-disable-next-line posthog/warn-elements
{
diff --git a/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.scss b/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.scss
index 087aa0f39c5d8..67200ac17bdf3 100644
--- a/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.scss
+++ b/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.scss
@@ -4,7 +4,7 @@
.ant-select-selector,
&.ant-select-single .ant-select-selector {
- min-height: 2.5rem;
+ min-height: 2.125rem;
padding: 0.25rem;
font-size: 0.875rem;
line-height: 1.25rem;
@@ -13,10 +13,6 @@
border: 1px solid var(--border);
border-radius: var(--radius);
- .posthog-3000 & {
- min-height: 2.125rem;
- }
-
.ant-select-selection-overflow {
gap: 0.25rem;
}
@@ -71,13 +67,9 @@
padding: 0.5rem;
margin: -4px 0; // Counteract antd wrapper
background: var(--bg-light);
- border: 1px solid var(--primary);
+ border: 1px solid var(--primary-3000);
border-radius: var(--radius);
- .posthog-3000 & {
- border: 1px solid var(--primary-3000);
- }
-
.ant-select-item {
padding: 0;
padding-bottom: 0.2rem;
diff --git a/frontend/src/lib/lemon-ui/LemonSwitch/LemonSwitch.scss b/frontend/src/lib/lemon-ui/LemonSwitch/LemonSwitch.scss
index eccd270e72e1f..a63c3e5fdc63c 100644
--- a/frontend/src/lib/lemon-ui/LemonSwitch/LemonSwitch.scss
+++ b/frontend/src/lib/lemon-ui/LemonSwitch/LemonSwitch.scss
@@ -1,6 +1,6 @@
.LemonSwitch {
- --lemon-switch-height: 1.25rem;
- --lemon-switch-width: 2.25rem;
+ --lemon-switch-height: 1.125rem;
+ --lemon-switch-width: calc(11 / 6 * var(--lemon-switch-height)); // Same proportion as in IconToggle
display: flex;
gap: 0.5rem;
@@ -23,17 +23,13 @@
}
&.LemonSwitch--bordered {
- min-height: 2.5rem;
+ min-height: calc(2.125rem + 3px); // Medium size button height + button shadow height
padding: 0 0.75rem;
line-height: 1.4;
background: var(--bg-light);
border: 1px solid var(--border);
border-radius: var(--radius);
- .posthog-3000 & {
- min-height: calc(2.125rem + 3px); // Medium size button height + button shadow height
- }
-
&.LemonSwitch--small {
gap: 0.5rem;
min-height: 2rem;
@@ -54,11 +50,6 @@
cursor: not-allowed; // A label with for=* also toggles the switch, so it shouldn't have the text select cursor
}
}
-
- .posthog-3000 & {
- --lemon-switch-height: 1.125rem;
- --lemon-switch-width: calc(11 / 6 * var(--lemon-switch-height)); // Same proportion as in IconToggle
- }
}
.LemonSwitch__button {
@@ -79,91 +70,57 @@
.LemonSwitch__slider {
position: absolute;
- top: 5px;
+ top: 0;
left: 0;
display: inline-block;
- width: 2.25rem;
- height: 0.625rem;
- background-color: var(--border);
- border-radius: 0.625rem;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ background-color: var(--border-bold);
+ border-radius: var(--lemon-switch-height);
transition: background-color 100ms ease;
- .posthog-3000 & {
- top: 0;
- width: 100%;
- height: 100%;
- pointer-events: none;
- background-color: var(--border-bold);
- border-radius: var(--lemon-switch-height);
- }
-
.LemonSwitch--checked & {
- background-color: var(--primary-highlight);
-
- .posthog-3000 & {
- background-color: var(--primary-3000);
- }
+ background-color: var(--primary-3000);
}
}
.LemonSwitch__handle {
+ --lemon-switch-handle-ratio: calc(3 / 4); // Same proportion as in IconToggle
+ --lemon-switch-handle-gutter: calc(var(--lemon-switch-height) * calc(1 - var(--lemon-switch-handle-ratio)) / 2);
+ --lemon-switch-handle-width: calc(var(--lemon-switch-height) * var(--lemon-switch-handle-ratio));
+ --lemon-switch-active-translate: translateX(
+ calc(var(--lemon-switch-width) - var(--lemon-switch-handle-width) - var(--lemon-switch-handle-gutter) * 2)
+ );
+
position: absolute;
- top: 0;
- left: 0;
+ top: var(--lemon-switch-handle-gutter);
+ left: var(--lemon-switch-handle-gutter);
display: flex;
align-items: center;
justify-content: center;
- width: 1.25rem;
- height: 1.25rem;
+ width: var(--lemon-switch-handle-width);
+ height: calc(var(--lemon-switch-height) * var(--lemon-switch-handle-ratio));
+ pointer-events: none;
cursor: inherit;
background-color: #fff;
- border: 2px solid var(--border);
+ border: none;
border-radius: 0.625rem;
transition: background-color 100ms ease, transform 100ms ease, width 100ms ease, border-color 100ms ease;
- .posthog-3000 & {
- --lemon-switch-handle-ratio: calc(3 / 4); // Same proportion as in IconToggle
- --lemon-switch-handle-gutter: calc(var(--lemon-switch-height) * calc(1 - var(--lemon-switch-handle-ratio)) / 2);
- --lemon-switch-handle-width: calc(var(--lemon-switch-height) * var(--lemon-switch-handle-ratio));
- --lemon-switch-active-translate: translateX(
- calc(var(--lemon-switch-width) - var(--lemon-switch-handle-width) - var(--lemon-switch-handle-gutter) * 2)
- );
-
- top: var(--lemon-switch-handle-gutter);
- left: var(--lemon-switch-handle-gutter);
- width: var(--lemon-switch-handle-width);
- height: calc(var(--lemon-switch-height) * var(--lemon-switch-handle-ratio));
- pointer-events: none;
- background-color: #fff;
- border: none;
- }
-
.LemonSwitch--checked & {
- background-color: var(--primary-3000);
+ background-color: #fff;
border-color: var(--primary-3000);
- transform: translateX(1rem);
-
- .posthog-3000 & {
- background-color: #fff;
- transform: var(--lemon-switch-active-translate);
- }
+ transform: var(--lemon-switch-active-translate);
}
.LemonSwitch--active & {
- transform: scale(1.1);
-
- .posthog-3000 & {
- --lemon-switch-handle-width: calc(var(--lemon-switch-height) * var(--lemon-switch-handle-ratio) * 1.2);
+ --lemon-switch-handle-width: calc(var(--lemon-switch-height) * var(--lemon-switch-handle-ratio) * 1.2);
- transform: none;
- }
+ transform: none;
}
.LemonSwitch--active.LemonSwitch--checked & {
- transform: translateX(1rem) scale(1.1);
-
- .posthog-3000 & {
- transform: var(--lemon-switch-active-translate);
- }
+ transform: var(--lemon-switch-active-translate);
}
}
diff --git a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.scss b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.scss
index 2bf5449f0b4aa..b944d2f17635e 100644
--- a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.scss
+++ b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.scss
@@ -1,5 +1,5 @@
.LemonTable {
- --row-base-height: 3rem;
+ --row-base-height: auto;
--row-horizontal-padding: 1rem;
--lemon-table-background-color: var(--bg-table);
@@ -7,6 +7,7 @@
flex: 1;
width: 100%;
overflow: hidden;
+ font-size: 13px;
background: var(--lemon-table-background-color);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -24,12 +25,6 @@
border: none;
}
- .posthog-3000 & {
- --row-base-height: auto;
-
- font-size: 13px;
- }
-
&.LemonTable--with-ribbon {
--row-ribbon-width: 0.25rem;
@@ -49,18 +44,12 @@
}
&--xs {
- --row-base-height: 2rem;
-
.LemonTable__content > table > tbody > tr > td {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
}
- &--small {
- --row-base-height: 2.5rem;
- }
-
&--embedded {
background: none;
border: none;
@@ -72,11 +61,8 @@
.LemonTable__content > table {
> thead {
+ background: none;
border-bottom: none;
-
- .posthog-3000 & {
- background: none;
- }
}
> thead,
@@ -127,12 +113,10 @@
}
a.Link {
- .posthog-3000 & {
- color: var(--default);
+ color: var(--default);
- &:not(:disabled):hover {
- color: var(--primary-3000-hover);
- }
+ &:not(:disabled):hover {
+ color: var(--primary-3000-hover);
}
}
}
@@ -147,14 +131,12 @@
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03125rem;
- background: var(--mid);
-
- .posthog-3000 & {
- background: var(--lemon-table-background-color);
- }
+ background: var(--lemon-table-background-color);
> tr {
> th {
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
font-weight: 700;
text-align: left;
@@ -162,15 +144,8 @@
// Also it needs to be on the th - any higher and safari will not render the shadow
box-shadow: inset 0 -1px var(--border);
- .posthog-3000 & {
- padding-top: 0.5rem;
- padding-bottom: 0.5rem;
- }
-
.LemonButton {
- .posthog-3000 & {
- margin: -0.5rem 0;
- }
+ margin: -0.5rem 0;
}
}
@@ -293,30 +268,26 @@
.LemonTable__header {
cursor: default;
- .posthog-3000 & {
- .LemonTable__header-content {
- color: var(--text-secondary);
- }
+ .LemonTable__header-content {
+ color: var(--text-secondary);
}
&.LemonTable__header--actionable {
cursor: pointer;
- .posthog-3000 & {
- &:hover {
- &:not(:has(.LemonTable__header--no-hover:hover)) {
- .LemonTable__header-content {
- color: var(--default);
- }
- }
- }
-
- &:active {
+ &:hover {
+ &:not(:has(.LemonTable__header--no-hover:hover)) {
.LemonTable__header-content {
color: var(--default);
}
}
}
+
+ &:active {
+ .LemonTable__header-content {
+ color: var(--default);
+ }
+ }
}
}
@@ -346,11 +317,7 @@
}
.LemonTable__header--sticky::before {
- background: var(--mid);
-
- .posthog-3000 & {
- background: var(--lemon-table-background-color);
- }
+ background: var(--lemon-table-background-color);
}
// Stickiness is disabled in snapshots due to flakiness
diff --git a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.stories.tsx b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.stories.tsx
index b0c604b5e2cf7..a9c360b431cfb 100644
--- a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.stories.tsx
+++ b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.stories.tsx
@@ -176,9 +176,6 @@ WithExpandableRows.args = {
export const Small: Story = BasicTemplate.bind({})
Small.args = { size: 'small' }
-export const XSmall: Story = BasicTemplate.bind({})
-XSmall.args = { size: 'xs' }
-
export const Embedded: Story = BasicTemplate.bind({})
Embedded.args = { embedded: true }
diff --git a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx
index 7cba42509ac08..8fb3ee58a65ae 100644
--- a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx
+++ b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx
@@ -47,7 +47,7 @@ export interface LemonTableProps> {
/** Function that for each row determines what props should its `tr` element have based on the row's record. */
onRow?: (record: T) => Omit, 'key'>
/** How tall should rows be. The default value is `"middle"`. */
- size?: 'xs' | 'small' | 'middle'
+ size?: 'small' | 'middle'
/** Whether this table already is inset, meaning it needs reduced horizontal padding (0.5rem instead of 1rem). */
inset?: boolean
/** An embedded table has no border around it and no background. This way it blends better into other components. */
diff --git a/frontend/src/lib/lemon-ui/LemonTable/LemonTableLoader.scss b/frontend/src/lib/lemon-ui/LemonTable/LemonTableLoader.scss
index c75c739b37653..49a382f7bd061 100644
--- a/frontend/src/lib/lemon-ui/LemonTable/LemonTableLoader.scss
+++ b/frontend/src/lib/lemon-ui/LemonTable/LemonTableLoader.scss
@@ -7,14 +7,10 @@
height: 0;
padding: 0.05rem !important;
overflow: hidden;
- background: var(--primary-bg-active);
+ background: var(--primary-3000-highlight);
border: none !important;
transition: height 200ms ease, top 200ms ease;
- .posthog-3000 & {
- background: var(--primary-3000-highlight);
- }
-
&::after {
position: absolute;
top: 0;
@@ -22,12 +18,8 @@
width: 50%;
height: 100%;
content: '';
- background: var(--primary);
+ background: var(--primary-3000);
animation: LemonTableLoader__swooping 1.5s linear infinite;
-
- .posthog-3000 & {
- background: var(--primary-3000);
- }
}
&.LemonTableLoader--enter-active,
diff --git a/frontend/src/lib/lemon-ui/LemonTag/LemonTag.scss b/frontend/src/lib/lemon-ui/LemonTag/LemonTag.scss
index 93f2d8a133165..807b7765e3420 100644
--- a/frontend/src/lib/lemon-ui/LemonTag/LemonTag.scss
+++ b/frontend/src/lib/lemon-ui/LemonTag/LemonTag.scss
@@ -25,14 +25,9 @@
}
&.LemonTag--primary {
- color: #fff;
- background-color: var(--primary-3000);
-
- .posthog-3000 & {
- color: var(--primary-3000);
- background: none;
- border-color: var(--primary-3000);
- }
+ color: var(--primary-3000);
+ background: none;
+ border-color: var(--primary-3000);
}
&.LemonTag--option {
@@ -41,69 +36,39 @@
}
&.LemonTag--highlight {
- color: var(--bg-charcoal);
- background-color: var(--mark);
-
- .posthog-3000 & {
- color: var(--highlight);
- background: none;
- border-color: var(--highlight);
- }
+ color: var(--highlight);
+ background: none;
+ border-color: var(--highlight);
}
&.LemonTag--warning {
- color: var(--bg-charcoal);
- background-color: var(--warning);
-
- .posthog-3000 & {
- color: var(--warning);
- background: none;
- border-color: var(--warning);
- }
+ color: var(--warning);
+ background-color: none;
+ border-color: var(--warning);
}
&.LemonTag--danger {
- color: #fff;
- background-color: var(--danger);
-
- .posthog-3000 & {
- color: var(--danger);
- background: none;
- border-color: var(--danger);
- }
+ color: var(--danger);
+ background: none;
+ border-color: var(--danger);
}
&.LemonTag--success {
- color: #fff;
- background-color: var(--success);
-
- .posthog-3000 & {
- color: var(--success);
- background: none;
- border-color: var(--success);
- }
+ color: var(--success);
+ background: none;
+ border-color: var(--success);
}
&.LemonTag--completion {
- color: var(--bg-charcoal);
- background-color: var(--purple-light);
-
- .posthog-3000 & {
- color: var(--purple);
- background: none;
- border-color: var(--purple);
- }
+ color: var(--purple);
+ background: none;
+ border-color: var(--purple);
}
&.LemonTag--caution {
- color: var(--bg-charcoal);
- background-color: var(--danger-lighter);
-
- .posthog-3000 & {
- color: var(--danger-lighter);
- background: none;
- border-color: var(--danger-lighter);
- }
+ color: var(--danger-lighter);
+ background: none;
+ border-color: var(--danger-lighter);
}
&.LemonTag--muted {
diff --git a/frontend/src/lib/lemon-ui/LemonTextArea/LemonTextArea.scss b/frontend/src/lib/lemon-ui/LemonTextArea/LemonTextArea.scss
index 3c24f908503e9..9c89b575b4a0b 100644
--- a/frontend/src/lib/lemon-ui/LemonTextArea/LemonTextArea.scss
+++ b/frontend/src/lib/lemon-ui/LemonTextArea/LemonTextArea.scss
@@ -17,11 +17,7 @@
transition: background-color 200ms ease, color 200ms ease, border 200ms ease, opacity 200ms ease;
&:not(:disabled):hover {
- border: 1px solid var(--primary-3000-hover);
-
- .posthog-3000 & {
- border-color: var(--border-bold);
- }
+ border: 1px solid var(--border-bold);
}
&:disabled {
@@ -30,11 +26,7 @@
}
&:focus:not(:disabled) {
- border: 1px solid var(--primary-3000);
-
- .posthog-3000 & {
- border-color: var(--border-active);
- }
+ border: 1px solid var(--border-active);
}
.Field--error & {
diff --git a/frontend/src/lib/lemon-ui/Link/Link.scss b/frontend/src/lib/lemon-ui/Link/Link.scss
index 13969c9df18b1..24a0bf5f65522 100644
--- a/frontend/src/lib/lemon-ui/Link/Link.scss
+++ b/frontend/src/lib/lemon-ui/Link/Link.scss
@@ -28,12 +28,10 @@
}
&--subtle {
- .posthog-3000 & {
- color: var(--default);
+ color: var(--default);
- &:not(:disabled):hover {
- color: var(--primary-3000-hover);
- }
+ &:not(:disabled):hover {
+ color: var(--primary-3000-hover);
}
}
}
diff --git a/frontend/src/lib/lemon-ui/Spinner/Spinner.scss b/frontend/src/lib/lemon-ui/Spinner/Spinner.scss
index e5ddf2a3cb175..3ab31c08e9be6 100644
--- a/frontend/src/lib/lemon-ui/Spinner/Spinner.scss
+++ b/frontend/src/lib/lemon-ui/Spinner/Spinner.scss
@@ -78,7 +78,7 @@
position: relative;
}
- .posthog-3000 &.SpinnerOverlay--scene-level::before {
+ &.SpinnerOverlay--scene-level::before {
background: var(--bg-3000);
}
}
diff --git a/frontend/src/lib/lemon-ui/Spinner/Spinner.stories.tsx b/frontend/src/lib/lemon-ui/Spinner/Spinner.stories.tsx
index 9353443d74ba0..39239ea7e46a4 100644
--- a/frontend/src/lib/lemon-ui/Spinner/Spinner.stories.tsx
+++ b/frontend/src/lib/lemon-ui/Spinner/Spinner.stories.tsx
@@ -95,3 +95,21 @@ export function AsOverlay(): JSX.Element {
)
}
+
+export function asOverlayWaiting(): JSX.Element {
+ return (
+
+
Hey there
+
+ Before showing something loading, you might want to show a message to the user to let them know what's
+ happening. This is especially useful when the loading might take a while. This is a good place to put
+ that message. It's also a good place to put a message that tells the user what to do if the loading is
+ taking too long. When you're ready to show the spinner, you can use the `mode` prop to change the
+ spinner to a waiting spinner. This will give the user a visual indication that the loading is still
+ about to happen.
+
+
+
+
+ )
+}
diff --git a/frontend/src/lib/lemon-ui/Spinner/Spinner.tsx b/frontend/src/lib/lemon-ui/Spinner/Spinner.tsx
index 5939bc14ec114..0d3c3e97a3fcf 100644
--- a/frontend/src/lib/lemon-ui/Spinner/Spinner.tsx
+++ b/frontend/src/lib/lemon-ui/Spinner/Spinner.tsx
@@ -1,6 +1,7 @@
import './Spinner.scss'
import clsx from 'clsx'
+import { IconSchedule } from 'lib/lemon-ui/icons'
export interface SpinnerProps {
textColored?: boolean
@@ -29,16 +30,23 @@ export function SpinnerOverlay({
sceneLevel,
visible = true,
className,
+ mode = 'spinning',
...spinnerProps
}: SpinnerProps & {
/** @default false */
sceneLevel?: boolean
/** @default true */
visible?: boolean
+ /** @default "spinning" */
+ mode?: 'spinning' | 'waiting'
}): JSX.Element {
return (
-
+ {mode === 'waiting' ? (
+
+ ) : (
+
+ )}
)
}
diff --git a/frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts b/frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts
deleted file mode 100644
index b8ede572b2214..0000000000000
--- a/frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { actions, kea, listeners, path } from 'kea'
-import posthog from 'posthog-js'
-
-import type { inAppPromptEventCaptureLogicType } from './inAppPromptEventCaptureLogicType'
-import { PromptType } from './inAppPromptLogic'
-
-const inAppPromptEventCaptureLogic = kea
([
- path(['lib', 'logic', 'inAppPrompt', 'eventCapture']),
- actions({
- reportPromptShown: (type: PromptType, sequence: string, step: number, totalSteps: number) => ({
- type,
- sequence,
- step,
- totalSteps,
- }),
- reportPromptForward: (sequence: string, step: number, totalSteps: number) => ({ sequence, step, totalSteps }),
- reportPromptBackward: (sequence: string, step: number, totalSteps: number) => ({ sequence, step, totalSteps }),
- reportPromptSequenceDismissed: (sequence: string, step: number, totalSteps: number) => ({
- sequence,
- step,
- totalSteps,
- }),
- reportPromptSequenceCompleted: (sequence: string, step: number, totalSteps: number) => ({
- sequence,
- step,
- totalSteps,
- }),
- reportProductTourStarted: true,
- reportProductTourSkipped: true,
- }),
- listeners({
- reportPromptShown: ({ type, sequence, step, totalSteps }) => {
- posthog.capture('prompt shown', {
- type,
- sequence,
- step,
- totalSteps,
- })
- },
- reportPromptForward: ({ sequence, step, totalSteps }) => {
- posthog.capture('prompt forward', {
- sequence,
- step,
- totalSteps,
- })
- },
- reportPromptBackward: ({ sequence, step, totalSteps }) => {
- posthog.capture('prompt backward', {
- sequence,
- step,
- totalSteps,
- })
- },
- reportPromptSequenceDismissed: ({ sequence, step, totalSteps }) => {
- posthog.capture('prompt sequence dismissed', {
- sequence,
- step,
- totalSteps,
- })
- },
- reportPromptSequenceCompleted: ({ sequence, step, totalSteps }) => {
- posthog.capture('prompt sequence completed', {
- sequence,
- step,
- totalSteps,
- })
- },
- reportProductTourStarted: () => {
- posthog.capture('product tour started')
- },
- reportProductTourSkipped: () => {
- posthog.capture('product tour skipped')
- },
- }),
-])
-
-export { inAppPromptEventCaptureLogic }
diff --git a/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.test.ts b/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.test.ts
deleted file mode 100644
index e1a029a9d2db0..0000000000000
--- a/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.test.ts
+++ /dev/null
@@ -1,401 +0,0 @@
-import { router } from 'kea-router'
-import { expectLogic } from 'kea-test-utils'
-import api from 'lib/api'
-import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
-import { urls } from 'scenes/urls'
-
-import { useMocks } from '~/mocks/jest'
-import { initKeaTests } from '~/test/init'
-
-import { inAppPromptEventCaptureLogic } from './inAppPromptEventCaptureLogic'
-import { inAppPromptLogic, PromptConfig, PromptUserState } from './inAppPromptLogic'
-
-const configProductTours: PromptConfig & { state: PromptUserState } = {
- sequences: [
- {
- key: 'experiment-events-product-tour',
- prompts: [
- {
- step: 0,
- type: 'tooltip',
- text: "Welcome! We'd like to give you a quick tour!",
- placement: 'top-start',
- buttons: [{ action: 'skip', label: 'Skip tutorial' }],
- reference: 'tooltip-test',
- icon: 'live-events',
- },
- {
- step: 1,
- type: 'tooltip',
- text: "Here you can see all events from the past 12 months. Things look a bit quiet, so let's turn on automatic refresh to see events in real-time.",
- placement: 'top-start',
- reference: 'tooltip-test',
- icon: 'live-events',
- },
- {
- step: 2,
- type: 'tooltip',
- text: "If you aren't seeing the data you expect then you can always ask for help. For now, lets analyze some data. Click 'Dashboards' in the sidebar.",
- placement: 'top-start',
- buttons: [{ url: 'https://posthog.com/questions', label: 'Ask for help' }],
- icon: 'live-events',
- reference: 'tooltip-test',
- },
- ],
- path_match: ['/events'],
- path_exclude: [],
- type: 'product-tour',
- },
- {
- key: 'experiment-dashboards-product-tour',
- prompts: [
- {
- step: 0,
- type: 'tooltip',
- text: "In PostHog, you analyse data with Insights which can be added to Dashboards to aid collaboration. Let's create a new Dashboard by selecting 'New Dashboard'. ",
- placement: 'top-start',
- icon: 'dashboard',
- reference: 'tooltip-test',
- },
- {
- step: 1,
- type: 'tooltip',
- text: "In PostHog, you analyse data with Insights which can be added to Dashboards to aid collaboration. Let's create a new Dashboard by selecting 'New Dashboard'. ",
- placement: 'top-start',
- icon: 'dashboard',
- reference: 'tooltip-test',
- },
- ],
- path_match: ['/dashboard'],
- path_exclude: ['/dashboard/*'],
- type: 'product-tour',
- },
- ],
- state: {
- 'experiment-events-product-tour': {
- key: 'experiment-events-product-tour',
- step: 0,
- completed: false,
- dismissed: false,
- last_updated_at: '2022-07-26T16:32:55.153Z',
- },
- 'experiment-dashboards-product-tour': {
- key: 'experiment-dashboards-product-tour',
- step: null,
- completed: false,
- dismissed: false,
- last_updated_at: '2022-07-26T16:32:55.153Z',
- },
- },
-}
-
-const configOptIn: PromptConfig & { state: PromptUserState } = {
- sequences: [
- {
- key: 'experiment-one-off-intro',
- prompts: [
- {
- step: 0,
- type: 'tooltip',
- text: 'This is welcome message to ask users to opt-in',
- placement: 'top-start',
- icon: 'dashboard',
- reference: 'tooltip-test',
- },
- ],
- path_match: ['/*'],
- path_exclude: [],
- type: 'one-off',
- },
- {
- key: 'experiment-one-off',
- prompts: [
- {
- step: 0,
- type: 'tooltip',
- text: 'This is a one off prompt that requires opt-in',
- placement: 'top-start',
- icon: 'dashboard',
- reference: 'tooltip-test',
- },
- ],
- path_match: ['/*'],
- path_exclude: [],
- requires_opt_in: true,
- type: 'one-off',
- },
- ],
- state: {
- 'experiment-one-off-intro': {
- key: 'experiment-one-off-intro',
- step: null,
- completed: false,
- dismissed: false,
- last_updated_at: '2022-07-26T16:32:55.153Z',
- },
- 'experiment-one-off': {
- key: 'experiment-one-off',
- step: null,
- completed: false,
- dismissed: false,
- last_updated_at: '2022-07-26T16:32:55.153Z',
- },
- },
-}
-
-describe('inAppPromptLogic', () => {
- let logic: ReturnType
-
- describe('opt-in prompts', () => {
- beforeEach(async () => {
- const div = document.createElement('div')
- div['data-attr'] = 'tooltip-test'
- const spy = jest.spyOn(document, 'querySelector')
- spy.mockReturnValue(div)
- jest.spyOn(api, 'update')
- useMocks({
- patch: {
- '/api/prompts/my_prompts/': configOptIn,
- },
- })
- localStorage.clear()
- initKeaTests()
- featureFlagLogic.mount()
- logic = inAppPromptLogic()
- logic.mount()
- await expectLogic(logic).toMount([inAppPromptEventCaptureLogic])
- })
-
- afterEach(() => logic.unmount())
-
- it('correctly opts in', async () => {
- logic.actions.optInProductTour()
- await expectLogic(logic).toMatchValues({
- canShowProductTour: true,
- })
- })
-
- it('correctly opts out when skipping', async () => {
- logic.actions.optInProductTour()
- await expectLogic(logic, () => {
- logic.actions.promptAction('skip')
- })
- .toDispatchActions([
- 'closePrompts',
- 'optOutProductTour',
- inAppPromptEventCaptureLogic.actionCreators.reportProductTourSkipped(),
- ])
- .toMatchValues({
- canShowProductTour: false,
- })
- })
-
- it('correctly sets valid sequences respecting opt-out and opt-in', async () => {
- logic.actions.optOutProductTour()
- await expectLogic(logic, () => {
- logic.actions.syncState({ forceRun: true })
- })
- .toDispatchActions(['setSequences', 'findValidSequences', 'setValidSequences'])
- .toMatchValues({
- sequences: configOptIn.sequences,
- userState: configOptIn.state,
- canShowProductTour: false,
- validSequences: [
- {
- sequence: configOptIn.sequences[0],
- state: {
- step: 0,
- completed: false,
- },
- },
- ],
- })
-
- logic.actions.optInProductTour()
- logic.actions.findValidSequences()
- await expectLogic(logic).toMatchValues({
- canShowProductTour: true,
- validSequences: [
- {
- sequence: configOptIn.sequences[0],
- state: {
- step: 0,
- completed: false,
- },
- },
- {
- sequence: configOptIn.sequences[1],
- state: {
- step: 0,
- completed: false,
- },
- },
- ],
- })
- })
- })
-
- describe('product tours', () => {
- beforeEach(async () => {
- const div = document.createElement('div')
- div['data-attr'] = 'tooltip-test'
- const spy = jest.spyOn(document, 'querySelector')
- spy.mockReturnValue(div)
- jest.spyOn(api, 'update')
- useMocks({
- patch: {
- '/api/prompts/my_prompts/': configProductTours,
- },
- })
- localStorage.clear()
- initKeaTests()
- featureFlagLogic.mount()
- logic = inAppPromptLogic()
- logic.mount()
- logic.actions.optInProductTour()
- await expectLogic(logic).toMount([inAppPromptEventCaptureLogic])
- await expectLogic(logic, () => {
- logic.actions.syncState({ forceRun: true })
- })
- .toDispatchActions(['setUserState', 'setSequences', 'findValidSequences', 'setValidSequences'])
- .toMatchValues({
- sequences: configProductTours.sequences,
- userState: configProductTours.state,
- validSequences: [],
- })
- })
-
- afterEach(() => logic.unmount())
-
- it('changes route and dismissed the sequence in an excluded path', async () => {
- router.actions.push(urls.dashboard('my-dashboard'))
- await expectLogic(logic)
- .toDispatchActions(['closePrompts', 'findValidSequences', 'setValidSequences', 'runFirstValidSequence'])
- .toNotHaveDispatchedActions(['runSequence'])
- })
-
- it('changes route and correctly triggers an unseen sequence', async () => {
- router.actions.push(urls.dashboards())
- await expectLogic(logic)
- .toDispatchActions(['closePrompts', 'findValidSequences', 'setValidSequences'])
- .toMatchValues({
- validSequences: [
- {
- sequence: configProductTours.sequences[1],
- state: {
- step: 0,
- completed: false,
- },
- },
- ],
- })
- .toDispatchActions([
- 'closePrompts',
- logic.actionCreators.runSequence(configProductTours.sequences[1], 0),
- inAppPromptEventCaptureLogic.actionCreators.reportPromptShown(
- 'tooltip',
- configProductTours.sequences[1].key,
- 0,
- 2
- ),
- 'promptShownSuccessfully',
- ])
- .toMatchValues({
- currentSequence: configProductTours.sequences[1],
- currentStep: 0,
- })
- })
-
- it('can dismiss a sequence', async () => {
- router.actions.push(urls.dashboards())
- await expectLogic(logic).toDispatchActions(['promptShownSuccessfully']).toMatchValues({
- isPromptVisible: true,
- })
- await expectLogic(logic, () => {
- logic.actions.dismissSequence()
- })
- .toDispatchActions([
- inAppPromptEventCaptureLogic.actionCreators.reportPromptSequenceDismissed(
- configProductTours.sequences[1].key,
- 0,
- 2
- ),
- ])
- .toMatchValues({
- isPromptVisible: false,
- })
- })
-
- it('can complete sequence, then go back, then dismiss it', async () => {
- router.actions.push(urls.dashboards())
- await expectLogic(logic).toDispatchActions(['promptShownSuccessfully']).toMatchValues({
- isPromptVisible: true,
- })
- await expectLogic(logic, () => {
- logic.actions.nextPrompt()
- })
- .toDispatchActions([
- logic.actionCreators.runSequence(configProductTours.sequences[1], 1),
- inAppPromptEventCaptureLogic.actionCreators.reportPromptForward(
- configProductTours.sequences[1].key,
- 1,
- 2
- ),
- inAppPromptEventCaptureLogic.actionCreators.reportPromptSequenceCompleted(
- configProductTours.sequences[1].key,
- 1,
- 2
- ),
- inAppPromptEventCaptureLogic.actionCreators.reportPromptShown(
- 'tooltip',
- configProductTours.sequences[1].key,
- 1,
- 2
- ),
- 'promptShownSuccessfully',
- ])
- .toMatchValues({
- currentStep: 1,
- })
- await expectLogic(logic, () => {
- logic.actions.previousPrompt()
- })
- .toDispatchActions([
- logic.actionCreators.runSequence(configProductTours.sequences[1], 0),
- inAppPromptEventCaptureLogic.actionCreators.reportPromptBackward(
- configProductTours.sequences[1].key,
- 0,
- 2
- ),
- inAppPromptEventCaptureLogic.actionCreators.reportPromptShown(
- 'tooltip',
- configProductTours.sequences[1].key,
- 0,
- 2
- ),
- 'promptShownSuccessfully',
- ])
- .toMatchValues({
- currentStep: 0,
- })
- await expectLogic(logic, () => {
- logic.actions.dismissSequence()
- })
- .toDispatchActions(['clearSequence'])
- .toNotHaveDispatchedActions([
- inAppPromptEventCaptureLogic.actionCreators.reportPromptSequenceDismissed(
- configProductTours.sequences[0].key,
- 1,
- 2
- ),
- ])
- })
-
- it('does not run a sequence left unfinished', async () => {
- router.actions.push(urls.events())
- await expectLogic(logic).toNotHaveDispatchedActions(['promptShownSuccessfully']).toMatchValues({
- isPromptVisible: false,
- })
- })
- })
-})
diff --git a/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx b/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx
deleted file mode 100644
index 4c5cd22f04084..0000000000000
--- a/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx
+++ /dev/null
@@ -1,517 +0,0 @@
-import { Placement } from '@floating-ui/react'
-import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, reducers, selectors } from 'kea'
-import { router, urlToAction } from 'kea-router'
-import api from 'lib/api'
-import { now } from 'lib/dayjs'
-import {
- IconApps,
- IconBarChart,
- IconCoffee,
- IconCohort,
- IconComment,
- IconExperiment,
- IconFlag,
- IconGauge,
- IconLive,
- IconMessages,
- IconPerson,
- IconRecording,
- IconTools,
- IconTrendUp,
- IconUnverifiedEvent,
-} from 'lib/lemon-ui/icons'
-import {
- LemonActionableTooltip,
- LemonActionableTooltipProps,
-} from 'lib/lemon-ui/LemonActionableTooltip/LemonActionableTooltip'
-import { Lettermark } from 'lib/lemon-ui/Lettermark'
-import { createRoot } from 'react-dom/client'
-import wcmatch from 'wildcard-match'
-
-import { inAppPromptEventCaptureLogic } from './inAppPromptEventCaptureLogic'
-import type { inAppPromptLogicType } from './inAppPromptLogicType'
-
-/** To be extended with other types of notifications e.g. modals, bars */
-export type PromptType = 'tooltip'
-
-export type PromptButton = {
- url?: string
- action?: string
- label: string
-}
-
-export type Prompt = {
- step: number
- type: PromptType
- text: string
- placement: Placement
- reference: string | null
- title?: string
- buttons?: PromptButton[]
- icon?: string
-}
-
-export type Tooltip = Prompt & { type: 'tooltip' }
-
-export type PromptSequence = {
- key: string
- prompts: Prompt[]
- path_match: string[]
- path_exclude: string[]
- must_be_completed?: string[]
- requires_opt_in?: boolean
- type: string
-}
-
-export type PromptConfig = {
- sequences: PromptSequence[]
-}
-
-export type PromptState = {
- key: string
- last_updated_at: string
- step: number | null
- completed?: boolean
- dismissed?: boolean
-}
-
-export type ValidSequenceWithState = {
- sequence: PromptSequence
- state: { step: number; completed?: boolean }
-}
-
-export type PromptUserState = {
- [key: string]: PromptState
-}
-
-export enum DefaultAction {
- NEXT = 'next',
- PREVIOUS = 'previous',
- START_PRODUCT_TOUR = 'start-product-tour',
- SKIP = 'skip',
-}
-
-// we show a new sequence with 1 second delay, because users immediately dismiss prompts that are invasive
-const NEW_SEQUENCE_DELAY = 1000
-// make sure to change this prefix in case the schema of cached values is changed
-// otherwise the code will try to run with cached deprecated values
-const CACHE_PREFIX = 'v5'
-
-const iconMap = {
- home: ,
- 'live-events': ,
- dashboard: ,
- insight: ,
- messages: ,
- recordings: ,
- 'feature-flags': ,
- experiments: ,
- 'web-performance': ,
- 'data-management': ,
- persons: ,
- cohorts: ,
- annotations: ,
- apps: ,
- toolbar: ,
- 'trend-up': ,
-}
-
-/** Display a with the ability to remove it from the DOM */
-function cancellableTooltipWithRetries(
- tooltip: Tooltip,
- onAction: (action: string) => void,
- options: { maxSteps: number; onClose: () => void; next: () => void; previous: () => void }
-): { close: () => void; show: Promise } {
- let trigger = (): void => {}
- const close = (): number => window.setTimeout(trigger, 1)
- const show = new Promise((resolve, reject) => {
- const div = document.createElement('div')
- const root = createRoot(div)
- function destroy(): void {
- root.unmount()
- if (div.parentNode) {
- div.parentNode.removeChild(div)
- }
- }
-
- document.body.appendChild(div)
- trigger = destroy
-
- const tryRender = function (retries: number): void {
- try {
- let props: LemonActionableTooltipProps = {
- title: tooltip.title,
- text: tooltip.text,
- placement: tooltip.placement,
- step: tooltip.step,
- maxSteps: options.maxSteps,
- next: () => {
- destroy()
- options.next()
- },
- previous: () => {
- destroy()
- options.previous()
- },
- close: () => {
- destroy()
- options.onClose()
- },
- visible: true,
- buttons: tooltip.buttons
- ? tooltip.buttons.map((button) => {
- if (button.action) {
- return {
- ...button,
- action: () => onAction(button.action as string),
- }
- }
- return {
- url: button.url,
- label: button.label,
- }
- })
- : [],
- icon: tooltip.icon ? iconMap[tooltip.icon] : null,
- }
- if (tooltip.reference) {
- const element = tooltip.reference
- ? (document.querySelector(`[data-attr="${tooltip.reference}"]`) as HTMLElement)
- : null
- if (!element) {
- throw 'Prompt reference element not found'
- }
- props = { ...props, element }
- }
-
- root.render()
-
- resolve(true)
- } catch (e) {
- if (retries == 0) {
- reject(e)
- } else {
- setTimeout(function () {
- tryRender(retries - 1)
- }, 1000)
- }
- }
- }
- tryRender(3)
- })
-
- return {
- close,
- show,
- }
-}
-
-export const inAppPromptLogic = kea([
- path(['lib', 'logic', 'inAppPrompt']),
- connect(inAppPromptEventCaptureLogic),
- actions({
- findValidSequences: true,
- setValidSequences: (validSequences: ValidSequenceWithState[]) => ({ validSequences }),
- runFirstValidSequence: (options: { runDismissedOrCompleted?: boolean }) => ({ options }),
- runSequence: (sequence: PromptSequence, step: number) => ({ sequence, step }),
- promptShownSuccessfully: true,
- closePrompts: true,
- dismissSequence: true,
- clearSequence: true,
- nextPrompt: true,
- previousPrompt: true,
- updatePromptState: (update: Partial) => ({ update }),
- setUserState: (state: PromptUserState, sync = true) => ({ state, sync }),
- syncState: (options: { forceRun?: boolean }) => ({ options }),
- setSequences: (sequences: PromptSequence[]) => ({ sequences }),
- promptAction: (action: string) => ({ action }),
- optInProductTour: true,
- optOutProductTour: true,
- }),
- reducers(() => ({
- sequences: [
- [] as PromptSequence[],
- { persist: true, prefix: CACHE_PREFIX },
- {
- setSequences: (_, { sequences }) => sequences,
- },
- ],
- currentSequence: [
- null as PromptSequence | null,
- {
- runSequence: (_, { sequence }) => sequence,
- clearSequence: () => null,
- },
- ],
- currentStep: [
- 0,
- {
- runSequence: (_, { step }) => step,
- clearSequence: () => 0,
- },
- ],
- userState: [
- {} as PromptUserState,
- { persist: true, prefix: CACHE_PREFIX },
- {
- setUserState: (_, { state }) => state,
- },
- ],
- canShowProductTour: [
- false,
- { persist: true, prefix: CACHE_PREFIX },
- {
- optInProductTour: () => true,
- optOutProductTour: () => false,
- },
- ],
- validSequences: [
- [] as ValidSequenceWithState[],
- {
- setValidSequences: (_, { validSequences }) => validSequences,
- },
- ],
- validProductTourSequences: [
- [] as ValidSequenceWithState[],
- {
- setValidSequences: (_, { validSequences }) =>
- validSequences?.filter((v) => v.sequence.type === 'product-tour') || [],
- },
- ],
- isPromptVisible: [
- false,
- {
- promptShownSuccessfully: () => true,
- closePrompts: () => false,
- dismissSequence: () => false,
- },
- ],
- })),
- selectors(() => ({
- prompts: [(s) => [s.currentSequence], (sequence: PromptSequence | null) => sequence?.prompts ?? []],
- sequenceKey: [(s) => [s.currentSequence], (sequence: PromptSequence | null) => sequence?.key],
- })),
- listeners(({ actions, values, cache }) => ({
- syncState: async ({ options }, breakpoint) => {
- await breakpoint(100)
- try {
- const updatedState = await api.update(`api/prompts/my_prompts`, values.userState)
- if (updatedState) {
- if (JSON.stringify(values.userState) !== JSON.stringify(updatedState['state'])) {
- actions.setUserState(updatedState['state'], false)
- }
- if (
- JSON.stringify(values.sequences) !== JSON.stringify(updatedState['sequences']) ||
- options.forceRun
- ) {
- actions.setSequences(updatedState['sequences'])
- }
- }
- } catch (error: any) {
- console.error(error)
- }
- },
- closePrompts: () => cache.runOnClose?.(),
- setSequences: actions.findValidSequences,
- runSequence: async ({ sequence, step = 0 }) => {
- const prompt = sequence.prompts.find((prompt) => prompt.step === step)
- if (prompt) {
- switch (prompt.type) {
- case 'tooltip': {
- const { close, show } = cancellableTooltipWithRetries(prompt, actions.promptAction, {
- maxSteps: values.prompts.length,
- onClose: actions.dismissSequence,
- previous: () => actions.promptAction(DefaultAction.PREVIOUS),
- next: () => actions.promptAction(DefaultAction.NEXT),
- })
- cache.runOnClose = close
-
- try {
- await show
- const updatedState: Partial = {
- step: values.currentStep,
- }
- if (step === sequence.prompts.length - 1) {
- updatedState.completed = true
- inAppPromptEventCaptureLogic.actions.reportPromptSequenceCompleted(
- sequence.key,
- step,
- values.prompts.length
- )
- }
- actions.updatePromptState(updatedState)
- inAppPromptEventCaptureLogic.actions.reportPromptShown(
- prompt.type,
- sequence.key,
- step,
- values.prompts.length
- )
- actions.promptShownSuccessfully()
- } catch (e) {
- console.error(e)
- }
- break
- }
- default:
- break
- }
- }
- },
- updatePromptState: ({ update }) => {
- if (values.sequenceKey) {
- const key = values.sequenceKey
- const currentState = values.userState[key] || { key, step: 0 }
- actions.setUserState({
- ...values.userState,
- [key]: {
- ...currentState,
- ...update,
- last_updated_at: now().toISOString(),
- },
- })
- }
- },
- previousPrompt: () => {
- if (values.currentSequence) {
- actions.runSequence(values.currentSequence, values.currentStep - 1)
- inAppPromptEventCaptureLogic.actions.reportPromptBackward(
- values.currentSequence.key,
- values.currentStep,
- values.currentSequence.prompts.length
- )
- }
- },
- nextPrompt: () => {
- if (values.currentSequence) {
- actions.runSequence(values.currentSequence, values.currentStep + 1)
- inAppPromptEventCaptureLogic.actions.reportPromptForward(
- values.currentSequence.key,
- values.currentStep,
- values.currentSequence.prompts.length
- )
- }
- },
- findValidSequences: () => {
- const pathname = router.values.currentLocation.pathname
- const valid = []
- for (const sequence of values.sequences) {
- // for now the only valid rule is related to the pathname, can be extended
- const must_match = [...sequence.path_match]
- if (must_match.includes('/*')) {
- must_match.push('/**')
- }
- const isMatchingPath = must_match.some((value) => wcmatch(value)(pathname))
- if (!isMatchingPath) {
- continue
- }
- const isMatchingExclusion = sequence.path_exclude.some((value) => wcmatch(value)(pathname))
- if (isMatchingExclusion) {
- continue
- }
- const hasOptedInToSequence = sequence.requires_opt_in ? values.canShowProductTour : true
- if (!values.userState[sequence.key]) {
- continue
- }
- const sequenceState = values.userState[sequence.key]
- const completed = !!sequenceState.completed || sequenceState.step === sequence.prompts.length
- const canRun = !sequenceState.dismissed && hasOptedInToSequence
- if (!canRun) {
- continue
- }
- if (sequence.type !== 'product-tour' && (completed || sequenceState.step === sequence.prompts.length)) {
- continue
- }
- valid.push({
- sequence,
- state: {
- step: sequenceState.step ? sequenceState.step + 1 : 0,
- completed,
- },
- })
- }
- actions.setValidSequences(valid)
- },
- setValidSequences: () => {
- if (!values.isPromptVisible) {
- actions.runFirstValidSequence({})
- }
- },
- runFirstValidSequence: ({ options }) => {
- if (values.validSequences) {
- actions.closePrompts()
- let firstValid = null
- if (options.runDismissedOrCompleted) {
- firstValid = values.validSequences[0]
- } else {
- // to make it less greedy, we don't allow half-run sequences to be started automatically
- firstValid = values.validSequences.filter(
- (sequence) => !sequence.state.completed && sequence.state.step === 0
- )?.[0]
- }
- if (firstValid) {
- const { sequence, state } = firstValid
- setTimeout(() => actions.runSequence(sequence, state.step), NEW_SEQUENCE_DELAY)
- }
- }
- },
- dismissSequence: () => {
- if (values.sequenceKey) {
- const key = values.sequenceKey
- const currentState = values.userState[key]
- if (currentState && !currentState.completed) {
- actions.updatePromptState({
- dismissed: true,
- })
- if (values.currentStep < values.prompts.length) {
- inAppPromptEventCaptureLogic.actions.reportPromptSequenceDismissed(
- values.sequenceKey,
- values.currentStep,
- values.prompts.length
- )
- }
- }
- actions.clearSequence()
- }
- },
- setUserState: ({ sync }) => sync && actions.syncState({}),
- promptAction: ({ action }) => {
- actions.closePrompts()
- switch (action) {
- case DefaultAction.NEXT:
- actions.nextPrompt()
- break
- case DefaultAction.PREVIOUS:
- actions.previousPrompt()
- break
- case DefaultAction.START_PRODUCT_TOUR:
- actions.optInProductTour()
- inAppPromptEventCaptureLogic.actions.reportProductTourStarted()
- actions.runFirstValidSequence({ runDismissedOrCompleted: true })
- break
- case DefaultAction.SKIP:
- actions.optOutProductTour()
- inAppPromptEventCaptureLogic.actions.reportProductTourSkipped()
- break
- default: {
- const potentialSequence = values.sequences.find((s) => s.key === action)
- if (potentialSequence) {
- actions.runSequence(potentialSequence, 0)
- }
- break
- }
- }
- },
- })),
- urlToAction(({ actions }) => ({
- '*': () => {
- actions.closePrompts()
- if (!['login', 'signup', 'ingestion'].find((path) => router.values.location.pathname.includes(path))) {
- actions.findValidSequences()
- }
- },
- })),
- afterMount(({ actions }) => {
- actions.syncState({ forceRun: true })
- }),
- beforeUnmount(({ cache }) => cache.runOnClose?.()),
-])
diff --git a/frontend/src/lib/logic/promptLogic.tsx b/frontend/src/lib/logic/promptLogic.tsx
index 371cc7cdc7d25..d6f78532b8ed0 100644
--- a/frontend/src/lib/logic/promptLogic.tsx
+++ b/frontend/src/lib/logic/promptLogic.tsx
@@ -106,7 +106,7 @@ function Prompt({
)
}
-export function cancellablePrompt(config: Pick): {
+function cancellablePrompt(config: Pick): {
cancel: () => void
promise: Promise
} {
diff --git a/frontend/src/lib/taxonomy.tsx b/frontend/src/lib/taxonomy.tsx
index 4f7d470271b63..a703ebf73046e 100644
--- a/frontend/src/lib/taxonomy.tsx
+++ b/frontend/src/lib/taxonomy.tsx
@@ -61,6 +61,14 @@ export const CORE_FILTER_DEFINITIONS_BY_GROUP = {
label: 'Screen',
description: 'When a user loads a screen in a mobile app.',
},
+ $set: {
+ label: 'Set',
+ description: 'Setting person properties.',
+ },
+ $opt_in: {
+ label: 'Opt In',
+ description: 'When a user opts into analytics.',
+ },
$feature_flag_called: {
label: 'Feature Flag Called',
description: (
@@ -106,14 +114,6 @@ export const CORE_FILTER_DEFINITIONS_BY_GROUP = {
label: 'Rageclick',
description: 'A user has rapidly and repeatedly clicked in a single place',
},
- $set: {
- label: 'Set',
- description: 'Person properties to be set',
- },
- $set_once: {
- label: 'Set Once',
- description: 'Person properties to be set if not set already (i.e. first-touch)',
- },
$exception: {
label: 'Exception',
description: 'Automatically captured exceptions from the client Sentry integration',
@@ -178,6 +178,14 @@ export const CORE_FILTER_DEFINITIONS_BY_GROUP = {
},
event_properties: {
distinct_id: {} as CoreFilterDefinition, // Copied from metadata down below
+ $set: {
+ label: 'Set',
+ description: 'Person properties to be set',
+ },
+ $set_once: {
+ label: 'Set Once',
+ description: 'Person properties to be set if not set already (i.e. first-touch)',
+ },
$pageview_id: {
label: 'Pageview ID',
description: "PostHog's internal ID for matching events to a pageview.",
@@ -901,6 +909,7 @@ export const CORE_FILTER_DEFINITIONS_BY_GROUP = {
},
},
} satisfies Partial>>
+
CORE_FILTER_DEFINITIONS_BY_GROUP.person_properties = Object.fromEntries(
Object.entries(CORE_FILTER_DEFINITIONS_BY_GROUP.event_properties).flatMap(([key, value]) =>
eventToPersonProperties.has(key) || key.startsWith('$geoip_')
diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts
index 93d80167f6ca7..9025128a3ae11 100644
--- a/frontend/src/mocks/handlers.ts
+++ b/frontend/src/mocks/handlers.ts
@@ -111,8 +111,5 @@ export const defaultMocks: Mocks = {
'https://app.posthog.com/engage/': (): MockSignature => [200, 'ok'],
'/api/projects/:team_id/insights/:insight_id/viewed/': (): MockSignature => [201, null],
},
- patch: {
- '/api/prompts/my_prompts': (): MockSignature => [200, {}],
- },
}
export const handlers = mocksToHandlers(defaultMocks)
diff --git a/frontend/src/models/cohortsModel.ts b/frontend/src/models/cohortsModel.ts
index b095a9b946472..89b75b7e01404 100644
--- a/frontend/src/models/cohortsModel.ts
+++ b/frontend/src/models/cohortsModel.ts
@@ -8,6 +8,7 @@ import { permanentlyMount } from 'lib/utils/kea-logic-builders'
import { BehavioralFilterKey } from 'scenes/cohorts/CohortFilters/types'
import { personsLogic } from 'scenes/persons/personsLogic'
import { isAuthenticatedTeam, teamLogic } from 'scenes/teamLogic'
+import { urls } from 'scenes/urls'
import {
AnyCohortCriteriaType,
@@ -131,7 +132,7 @@ export const cohortsModel = kea([
listeners(({ actions }) => ({
loadCohortsSuccess: async ({ cohorts }: { cohorts: CohortType[] }) => {
const is_calculating = cohorts.filter((cohort) => cohort.is_calculating).length > 0
- if (!is_calculating) {
+ if (!is_calculating || !window.location.pathname.includes(urls.cohorts())) {
return
}
actions.setPollTimeout(window.setTimeout(actions.loadCohorts, POLL_TIMEOUT))
diff --git a/frontend/src/queries/nodes/DataNode/TestAccountFilters.tsx b/frontend/src/queries/nodes/DataNode/TestAccountFilters.tsx
new file mode 100644
index 0000000000000..1bef791f6035c
--- /dev/null
+++ b/frontend/src/queries/nodes/DataNode/TestAccountFilters.tsx
@@ -0,0 +1,77 @@
+import { useActions, useValues } from 'kea'
+import { IconSettings } from 'lib/lemon-ui/icons'
+import { LemonButton } from 'lib/lemon-ui/LemonButton'
+import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch'
+import { filterTestAccountsDefaultsLogic } from 'scenes/settings/project/filterTestAccountDefaultsLogic'
+import { teamLogic } from 'scenes/teamLogic'
+import { urls } from 'scenes/urls'
+
+import { DataNode, EventsQuery, HogQLQuery } from '~/queries/schema'
+import { isEventsQuery, isHogQLQuery } from '~/queries/utils'
+
+interface TestAccountFiltersProps {
+ query: DataNode
+ setQuery?: (query: EventsQuery | HogQLQuery) => void
+}
+export function TestAccountFilters({ query, setQuery }: TestAccountFiltersProps): JSX.Element | null {
+ const { currentTeam } = useValues(teamLogic)
+ const hasFilters = (currentTeam?.test_account_filters || []).length > 0
+ const { setLocalDefault } = useActions(filterTestAccountsDefaultsLogic)
+
+ if (!isEventsQuery(query) && !isHogQLQuery(query)) {
+ return null
+ }
+ const checked = hasFilters
+ ? !!(isHogQLQuery(query)
+ ? query.filters?.filterTestAccounts
+ : isEventsQuery(query)
+ ? query.filterTestAccounts
+ : false)
+ : false
+ const onChange = isHogQLQuery(query)
+ ? (checked: boolean) => {
+ const newQuery: HogQLQuery = {
+ ...query,
+ filters: {
+ ...query.filters,
+ filterTestAccounts: checked,
+ },
+ }
+ setQuery?.(newQuery)
+ }
+ : isEventsQuery(query)
+ ? (checked: boolean) => {
+ const newQuery: EventsQuery = {
+ ...query,
+ filterTestAccounts: checked,
+ }
+ setQuery?.(newQuery)
+ }
+ : undefined
+
+ return (
+ {
+ onChange?.(checked)
+ setLocalDefault(checked)
+ }}
+ id="test-account-filter"
+ bordered
+ label={
+
+ Filter out internal and test users
+ }
+ to={urls.settings('project-product-analytics', 'internal-user-filtering')}
+ size="small"
+ noPadding
+ className="ml-1"
+ />
+
+ }
+ disabledReason={!hasFilters ? "You haven't set any internal and test filters" : null}
+ />
+ )
+ return null
+}
diff --git a/frontend/src/queries/nodes/DataTable/DataTable.tsx b/frontend/src/queries/nodes/DataTable/DataTable.tsx
index a130a03e092f9..4ad6278ed8424 100644
--- a/frontend/src/queries/nodes/DataTable/DataTable.tsx
+++ b/frontend/src/queries/nodes/DataTable/DataTable.tsx
@@ -19,6 +19,7 @@ import { DateRange } from '~/queries/nodes/DataNode/DateRange'
import { ElapsedTime } from '~/queries/nodes/DataNode/ElapsedTime'
import { LoadNext } from '~/queries/nodes/DataNode/LoadNext'
import { Reload } from '~/queries/nodes/DataNode/Reload'
+import { TestAccountFilters } from '~/queries/nodes/DataNode/TestAccountFilters'
import { BackToSource } from '~/queries/nodes/DataTable/BackToSource'
import { ColumnConfigurator } from '~/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator'
import { DataTableExport } from '~/queries/nodes/DataTable/DataTableExport'
@@ -130,6 +131,7 @@ export function DataTable({
const {
showActions,
showDateRange,
+ showTestAccountFilters,
showSearch,
showEventFilter,
showPropertyFilter,
@@ -420,6 +422,9 @@ export function DataTable({
].filter((x) => !!x)
const firstRowRight = [
+ showTestAccountFilters && sourceFeatures.has(QueryFeature.testAccountFilters) ? (
+
+ ) : null,
showSavedQueries && sourceFeatures.has(QueryFeature.savedEventsQueries) ? (
) : null,
diff --git a/frontend/src/queries/nodes/DataTable/dataTableLogic.ts b/frontend/src/queries/nodes/DataTable/dataTableLogic.ts
index ea5a114aa6f5f..99c6c2c2e234c 100644
--- a/frontend/src/queries/nodes/DataTable/dataTableLogic.ts
+++ b/frontend/src/queries/nodes/DataTable/dataTableLogic.ts
@@ -178,6 +178,7 @@ export const dataTableLogic = kea([
showSearch: query.showSearch ?? showIfFull,
showActions: query.showActions ?? true,
showDateRange: query.showDateRange ?? showIfFull,
+ showTestAccountFilters: query.showTestAccountFilters ?? showIfFull,
showExport: query.showExport ?? showIfFull,
showReload: query.showReload ?? showIfFull,
showTimings: query.showTimings ?? flagQueryTimingsEnabled,
diff --git a/frontend/src/queries/nodes/DataTable/queryFeatures.ts b/frontend/src/queries/nodes/DataTable/queryFeatures.ts
index ff10bec2b3137..4a6ae92296559 100644
--- a/frontend/src/queries/nodes/DataTable/queryFeatures.ts
+++ b/frontend/src/queries/nodes/DataTable/queryFeatures.ts
@@ -23,6 +23,7 @@ export enum QueryFeature {
selectAndOrderByColumns,
displayResponseError,
hideLoadNextButton,
+ testAccountFilters,
}
export function getQueryFeatures(query: Node): Set {
@@ -34,6 +35,7 @@ export function getQueryFeatures(query: Node): Set {
features.add(QueryFeature.eventPropertyFilters)
features.add(QueryFeature.resultIsArrayOfArrays)
features.add(QueryFeature.displayResponseError)
+ features.add(QueryFeature.testAccountFilters)
}
if (isEventsQuery(query)) {
diff --git a/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx
index 0e5066658245e..a7c5e4d9237f7 100644
--- a/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx
+++ b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx
@@ -203,7 +203,6 @@ export const LineGraph = (): JSX.Element => {
},
},
]}
- size="small"
uppercaseHeader={false}
rowRibbonColor={(_datum, index) => getSeriesColor(index)}
showHeader
diff --git a/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts b/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts
index d7efa47f6f9a6..fcf6b70df4932 100644
--- a/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts
+++ b/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts
@@ -79,11 +79,11 @@ export const dataVisualizationLogic = kea([
return []
}
- const columns: string[] = response['columns']
- const types: string[][] = response['types']
+ const columns: string[] = response['columns'] ?? []
+ const types: string[][] = response['types'] ?? []
return columns.map((column, index) => {
- const type = types[index][1]
+ const type = types[index]?.[1]
return {
name: column,
type,
diff --git a/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx b/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx
index 9bc09e1089cb7..45cb608451271 100644
--- a/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx
+++ b/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx
@@ -205,10 +205,6 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element {
monaco.languages.registerCompletionItemProvider('mysql', {
triggerCharacters: [' ', ',', '.'],
provideCompletionItems: async (model, position) => {
- if (!logic.isMounted()) {
- return undefined
- }
-
if (!featureFlags[FEATURE_FLAGS.HOGQL_AUTOCOMPLETE]) {
return undefined
}
@@ -226,7 +222,7 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element {
const response = await query({
kind: NodeKind.HogQLAutocomplete,
- select: logic.values.queryInput,
+ select: model.getValue(), // Use the text from the model instead of logic due to a race condition on the logic values updating quick enough
filters: props.query.filters,
startPosition: startOffset,
endPosition: endOffset,
@@ -239,7 +235,10 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element {
const sortText = kindToSortText(item.kind, item.label)
return {
- label: item.label,
+ label: {
+ label: item.label,
+ detail: item.detail,
+ },
documentation: item.documentation,
insertText: item.insertText,
range: {
@@ -262,6 +261,7 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element {
return {
suggestions,
+ incomplete: response.incomplete_list,
}
},
})
@@ -338,6 +338,9 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element {
scrollBeyondLastLine: false,
automaticLayout: true,
fixedOverflowWidgets: true,
+ suggest: {
+ showInlineDetails: true,
+ },
}}
/>
diff --git a/frontend/src/queries/nodes/InsightViz/EditorFilterGroup.tsx b/frontend/src/queries/nodes/InsightViz/EditorFilterGroup.tsx
index b7b3ec8bf4e2c..df2c131fdc499 100644
--- a/frontend/src/queries/nodes/InsightViz/EditorFilterGroup.tsx
+++ b/frontend/src/queries/nodes/InsightViz/EditorFilterGroup.tsx
@@ -1,9 +1,9 @@
import './EditorFilterGroup.scss'
-import { PureField } from 'lib/forms/Field'
import { IconUnfoldLess, IconUnfoldMore } from 'lib/lemon-ui/icons'
import { LemonBadge } from 'lib/lemon-ui/LemonBadge/LemonBadge'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
+import { LemonField } from 'lib/lemon-ui/LemonField'
import { slugify } from 'lib/utils'
import { Fragment, useState } from 'react'
@@ -49,13 +49,13 @@ export function EditorFilterGroup({ insightProps, editorFilterGroup }: EditorFil
}
return (