0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

Turbo mode (#6632)

* fix router redirect

* remove dependence on user var

* split scenes and sceneTypes out of sceneLogic

* rename LoadedScene type to SceneExport

* export SceneExport from most scenes

* use exported scene objects, warn if not exported

* fix type import bugs

* remove dashboard

* keep all loaded scene logics in memory

* fix sorting bugs

* support scenes with params, make it work with dashboards

* fetch result from dashboard if mounted

* fix mutations

* add lastTouch

* refactor scene parameters to include searchParams and hashParams

* add insights scene to scene router

* add insight router scene to scene router

* fix cohort back/forward bug

* this works

* bring back delayed loading of scenes

* set insight from dashboard also in afterMount

* split events, actions, event stats and properties stats into their own scenes

* refactor to options object for setInsight

* override filters

* clean filters for comparison

* fix cohort bug

* get a better feature flag

* make turbo mode faster by making non-turbo-mode slower

* less noise in failed tests

* fix tests

* flyby: add jest tests pycharm launcher

* clean up scenes

* add test for loading from dashboardLogic

* fix bug

* split test init code into two

* have the same data in the context and in the api

* add basic tests for sceneLogic

* run the latest and greatest

* fix menu highlight

* implement screaming snake

* only show scenes with logics
This commit is contained in:
Marius Andra 2021-10-26 22:08:45 +02:00 committed by GitHub
parent df583d528b
commit f76d0b6521
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 1104 additions and 593 deletions

11
.run/Jest Tests.run.xml Normal file
View File

@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Jest Tests" type="JavaScriptTestRunnerJest">
<node-interpreter value="project" />
<node-options value="" />
<jest-package value="$PROJECT_DIR$/node_modules/jest" />
<working-dir value="$PROJECT_DIR$" />
<envs />
<scope-kind value="ALL" />
<method v="2" />
</configuration>
</component>

View File

@ -13,7 +13,7 @@ import {
} from '@ant-design/icons'
import { useActions, useValues } from 'kea'
import { Link } from 'lib/components/Link'
import { Scene, sceneLogic } from 'scenes/sceneLogic'
import { sceneLogic } from 'scenes/sceneLogic'
import { urls } from 'scenes/urls'
import { isMobile } from 'lib/utils'
import { useEscapeKey } from 'lib/hooks/useEscapeKey'
@ -44,12 +44,16 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { FEATURE_FLAGS } from 'lib/constants'
import { Tooltip } from 'lib/components/Tooltip'
import { teamLogic } from 'scenes/teamLogic'
import { Scene } from 'scenes/sceneTypes'
// to show the right page in the sidebar
const sceneOverride: Partial<Record<Scene, string>> = {
action: 'actions',
person: 'persons',
dashboard: 'dashboards',
const sceneOverride: Partial<Record<Scene, Scene>> = {
[Scene.Action]: Scene.Events,
[Scene.Actions]: Scene.Events,
[Scene.EventStats]: Scene.Events,
[Scene.EventPropertyStats]: Scene.Events,
[Scene.Person]: Scene.Persons,
[Scene.Dashboard]: Scene.Dashboards,
}
interface MenuItemProps {

View File

@ -29,6 +29,12 @@ export const MOCK_ORGANIZATION_ID: OrganizationType['id'] = 'ABCD'
export const api = apiReal as any as APIMockReturnType
export const MOCK_DEFAULT_TEAM = {
id: MOCK_TEAM_ID,
ingested_event: true,
completed_snippet_onboarding: true,
}
export const mockAPI = (cb: (url: APIRoute) => any): void => {
beforeEach(async () => {
const methods = ['get', 'update', 'create', 'delete']
@ -52,11 +58,7 @@ export function defaultAPIMocks(
team: { ingested_event: true, completed_snippet_onboarding: true },
}
} else if (pathname === 'api/projects/@current') {
return {
id: MOCK_TEAM_ID,
ingested_event: true,
completed_snippet_onboarding: true,
}
return MOCK_DEFAULT_TEAM
} else if (pathname === 'api/organizations/@current') {
return {
id: MOCK_ORGANIZATION_ID,

View File

@ -247,6 +247,7 @@ export const FEATURE_FLAGS = {
SIGMA_ANALYSIS: 'sigma-analysis', // owner: @neilkakkar
NEW_SESSIONS_PLAYER: 'new-sessions-player', // owner: @rcmarron
BREAKDOWN_BY_MULTIPLE_PROPERTIES: '938-breakdown-by-multiple-properties', // owner: @pauldambra
TURBO_MODE: 'turbo-mode', // owner: @mariusandra
}
export const ENTITY_MATCH_TYPE = 'entities'

View File

@ -189,7 +189,7 @@ export const dashboardsModel = kea<dashboardsModelType>({
nameSortedDashboards: [
() => [selectors.rawDashboards],
(rawDashboards) => {
return Object.values(rawDashboards).sort((a, b) =>
return [...Object.values(rawDashboards)].sort((a, b) =>
(a.name ?? 'Untitled').localeCompare(b.name ?? 'Untitled')
)
},
@ -198,7 +198,7 @@ export const dashboardsModel = kea<dashboardsModelType>({
pinSortedDashboards: [
() => [selectors.nameSortedDashboards],
(nameSortedDashboards) => {
return nameSortedDashboards.sort(
return [...nameSortedDashboards].sort(
(a, b) =>
(Number(b.pinned) - Number(a.pinned)) * 10 +
(a.name ?? 'Untitled').localeCompare(b.name ?? 'Untitled')

View File

@ -18,6 +18,7 @@ import { models } from '~/models'
import { FEATURE_FLAGS } from 'lib/constants'
import { CloudAnnouncement } from '~/layout/navigation/CloudAnnouncement'
import { teamLogic } from './teamLogic'
import { LoadedScene } from 'scenes/sceneTypes'
export const appLogic = kea<appLogicType>({
actions: {
@ -64,11 +65,13 @@ export function App(): JSX.Element | null {
const { user } = useValues(userLogic)
const { currentTeamId } = useValues(teamLogic)
const { sceneConfig } = useValues(sceneLogic)
const { featureFlags } = useValues(featureFlagLogic)
if (showApp) {
return (
<>
{user && currentTeamId ? <Models /> : null}
{featureFlags[FEATURE_FLAGS.TURBO_MODE] ? <LoadedSceneLogics /> : null}
{(!sceneConfig.projectBased || currentTeamId) && <AppScene />}
</>
)
@ -77,6 +80,27 @@ export function App(): JSX.Element | null {
return showingDelayedSpinner ? <SceneLoading /> : null
}
function LoadedSceneLogic({ scene }: { scene: LoadedScene }): null {
if (!scene.logic) {
throw new Error('Loading scene without a logic')
}
useMountedLogic(scene.logic(scene.paramsToProps?.(scene.sceneParams)))
return null
}
function LoadedSceneLogics(): JSX.Element {
const { loadedScenes } = useValues(sceneLogic)
return (
<>
{Object.entries(loadedScenes)
.filter(([, { logic }]) => !!logic)
.map(([key, loadedScene]) => (
<LoadedSceneLogic key={key} scene={loadedScene} />
))}
</>
)
}
/** Loads every logic in the "src/models" folder */
function Models(): null {
useMountedLogic(models)

View File

@ -17,6 +17,7 @@ import { router } from 'kea-router'
import { PageHeader } from 'lib/components/PageHeader'
import { capitalizeFirstLetter } from 'lib/utils'
import { urls } from 'scenes/urls'
import { SceneExport } from 'scenes/sceneTypes'
interface PreflightItemInterface {
name: string
@ -29,6 +30,11 @@ interface CheckInterface extends PreflightItemInterface {
id: string
}
export const scene: SceneExport = {
component: PreflightCheck,
logic: preflightLogic,
}
function PreflightItem({ name, status, caption, failedState }: PreflightItemInterface): JSX.Element {
/*
status === undefined -> Item still loading (no positive or negative response yet)

View File

@ -18,6 +18,8 @@ import { PageHeader } from 'lib/components/PageHeader'
import { getBreakpoint } from 'lib/utils/responsiveUtils'
import { ColumnType } from 'antd/lib/table'
import { teamLogic } from '../teamLogic'
import { SceneExport } from 'scenes/sceneTypes'
import { EventsTab, EventsTabs } from 'scenes/events'
import api from '../../lib/api'
import { getCurrentTeamId } from '../../lib/utils/logics'
@ -30,6 +32,12 @@ const searchActions = (sources: ActionType[], search: string): ActionType[] => {
.map((result) => result.item)
}
export const scene: SceneExport = {
component: ActionsTable,
logic: actionsModel,
paramsToProps: () => ({ params: 'include_count=1' }),
}
export function ActionsTable(): JSX.Element {
const { currentTeam, currentTeamId } = useValues(teamLogic)
const { actions, actionsLoading } = useValues(actionsModel({ params: 'include_count=1' }))
@ -179,7 +187,8 @@ export function ActionsTable(): JSX.Element {
}
return (
<div>
<div data-attr="manage-events-table" style={{ paddingTop: 32 }}>
<EventsTabs tab={EventsTab.Actions} />
<PageHeader
title="Actions"
caption={

View File

@ -17,10 +17,16 @@ import dayjsGenerateConfig from 'rc-picker/lib/generate/dayjs'
import generatePicker from 'antd/es/date-picker/generatePicker'
import { normalizeColumnTitle, useIsTableScrolling } from 'lib/components/Table/utils'
import { teamLogic } from '../teamLogic'
import { SceneExport } from 'scenes/sceneTypes'
const DatePicker = generatePicker<dayjs.Dayjs>(dayjsGenerateConfig)
const { TextArea } = Input
export const scene: SceneExport = {
component: Annotations,
logic: annotationsTableLogic,
}
export function Annotations(): JSX.Element {
const { annotations, annotationsLoading, next, loadingNext } = useValues(annotationsTableLogic)
const { updateAnnotation, deleteAnnotation, loadAnnotationsNext, restoreAnnotation } =

View File

@ -16,6 +16,12 @@ import { preflightLogic } from 'scenes/PreflightCheck/logic'
import Checkbox from 'antd/lib/checkbox/Checkbox'
import smLogo from 'public/icon-white.svg'
import { urls } from 'scenes/urls'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: InviteSignup,
logic: inviteSignupLogic,
}
const UTM_TAGS = 'utm_medium=in-product&utm_campaign=invite-signup'
const PasswordStrength = lazy(() => import('../../lib/components/PasswordStrength'))

View File

@ -12,6 +12,12 @@ import { ExclamationCircleFilled } from '@ant-design/icons'
import clsx from 'clsx'
import { ErrorMessage } from 'lib/components/ErrorMessage/ErrorMessage'
import { WelcomeLogo } from './WelcomeLogo'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: Login,
logic: loginLogic,
}
export function Login(): JSX.Element {
const [form] = Form.useForm()

View File

@ -12,6 +12,12 @@ import { preflightLogic } from 'scenes/PreflightCheck/logic'
import { CodeSnippet, Language } from 'scenes/ingestion/frameworks/CodeSnippet'
import { passwordResetLogic } from './passwordResetLogic'
import { router } from 'kea-router'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: PasswordReset,
logic: passwordResetLogic,
}
export function PasswordReset(): JSX.Element {
const { preflight, preflightLoading } = useValues(preflightLogic)

View File

@ -11,6 +11,12 @@ import { ExclamationCircleFilled, StopOutlined } from '@ant-design/icons'
import { useActions, useValues } from 'kea'
import { passwordResetLogic } from './passwordResetLogic'
import { router } from 'kea-router'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: PasswordResetComplete,
logic: passwordResetLogic,
}
export function PasswordResetComplete(): JSX.Element {
const { validatedResetToken, validatedResetTokenLoading } = useValues(passwordResetLogic)

View File

@ -13,6 +13,12 @@ import { userLogic } from '../userLogic'
import { WelcomeLogo } from './WelcomeLogo'
import hedgehogMain from 'public/hedgehog-bridge-page.png'
import { ErrorMessage } from 'lib/components/ErrorMessage/ErrorMessage'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: Signup,
logic: signupLogic,
}
const UTM_TAGS = 'utm_campaign=in-product&utm_tag=signup-header'

View File

@ -6,6 +6,12 @@ import { BillingEnrollment } from './BillingEnrollment'
import { useValues } from 'kea'
import './Billing.scss'
import { billingLogic } from './billingLogic'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: Billing,
logic: billingLogic,
}
export function Billing(): JSX.Element {
const { billing } = useValues(billingLogic)

View File

@ -2,9 +2,10 @@ import { kea } from 'kea'
import api from 'lib/api'
import { billingLogicType } from './billingLogicType'
import { PlanInterface, BillingType } from '~/types'
import { sceneLogic, Scene } from 'scenes/sceneLogic'
import { sceneLogic } from 'scenes/sceneLogic'
import { preflightLogic } from 'scenes/PreflightCheck/logic'
import posthog from 'posthog-js'
import { Scene } from 'scenes/sceneTypes'
export const UTM_TAGS = 'utm_medium=in-product&utm_campaign=billing-management'
export const ALLOCATION_THRESHOLD_ALERT = 0.85 // Threshold to show warning of event usage near limit

View File

@ -19,6 +19,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import { cohortsUrlLogicType } from './CohortsType'
import { Link } from 'lib/components/Link'
import { PROPERTY_MATCH_TYPE } from 'lib/constants'
import { SceneExport } from 'scenes/sceneTypes'
dayjs.extend(relativeTime)
@ -60,6 +61,8 @@ const cohortsUrlLogic = kea<cohortsUrlLogicType>({
actions.setOpenCohort(cohort)
} else if (cohortId === 'new') {
actions.setOpenCohort(NEW_COHORT)
} else if (!cohortId) {
actions.setOpenCohort(null)
}
},
}),
@ -221,3 +224,8 @@ export function Cohorts(): JSX.Element {
</div>
)
}
export const scene: SceneExport = {
component: Cohorts,
logic: cohortsUrlLogic,
}

View File

@ -1,20 +1,21 @@
import React from 'react'
import { SceneLoading } from 'lib/utils'
import { BindLogic, useActions, useValues } from 'kea'
import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import { dashboardLogic, DashboardLogicProps } from 'scenes/dashboard/dashboardLogic'
import { DashboardHeader } from 'scenes/dashboard/DashboardHeader'
import { DashboardItems } from 'scenes/dashboard/DashboardItems'
import { dashboardsModel } from '~/models/dashboardsModel'
import { DateFilter } from 'lib/components/DateFilter/DateFilter'
import { CalendarOutlined } from '@ant-design/icons'
import './Dashboard.scss'
import { useKeyboardHotkeys } from '../../lib/hooks/useKeyboardHotkeys'
import { DashboardMode } from '../../types'
import { DashboardEventSource } from '../../lib/utils/eventUsageLogic'
import { useKeyboardHotkeys } from 'lib/hooks/useKeyboardHotkeys'
import { DashboardMode } from '~/types'
import { DashboardEventSource } from 'lib/utils/eventUsageLogic'
import { TZIndicator } from 'lib/components/TimezoneAware'
import { EmptyDashboardComponent } from './EmptyDashboardComponent'
import { NotFound } from 'lib/components/NotFound'
import { DashboardReloadAction, LastRefreshText } from 'scenes/dashboard/DashboardReloadAction'
import { SceneExport } from 'scenes/sceneTypes'
interface Props {
id?: string
@ -22,7 +23,13 @@ interface Props {
internal?: boolean
}
export function Dashboard({ id, shareToken, internal }: Props): JSX.Element {
export const scene: SceneExport = {
component: Dashboard,
logic: dashboardLogic,
paramsToProps: ({ params: { id } }): DashboardLogicProps => ({ id: parseInt(id) }),
}
export function Dashboard({ id, shareToken, internal }: Props = {}): JSX.Element {
return (
<BindLogic logic={dashboardLogic} props={{ id: id ? parseInt(id) : undefined, shareToken, internal }}>
<DashboardView />

View File

@ -21,6 +21,13 @@ import { userLogic } from 'scenes/userLogic'
import { ColumnType } from 'antd/lib/table'
import { DashboardEventSource } from 'lib/utils/eventUsageLogic'
import { urls } from 'scenes/urls'
import { SceneExport } from 'scenes/sceneTypes'
import { sceneLogic } from 'scenes/sceneLogic'
export const scene: SceneExport = {
component: Dashboards,
logic: sceneLogic,
}
export function Dashboards(): JSX.Element {
const { dashboardsLoading } = useValues(dashboardsModel)

View File

@ -1,58 +0,0 @@
import React from 'react'
import { kea, useActions, useValues } from 'kea'
import { Tabs } from 'antd'
import { ActionsTable } from 'scenes/actions/ActionsTable'
import { EventsTable } from './EventsTable'
import { EventsVolumeTable } from './EventsVolumeTable'
import { PropertiesVolumeTable } from './PropertiesVolumeTable'
import { eventsLogicType } from './EventsType'
import { DefinitionDrawer } from './definitions/DefinitionDrawer'
const eventsLogic = kea<eventsLogicType>({
actions: {
setTab: (tab: string) => ({ tab }),
},
reducers: {
tab: [
'live',
{
setTab: (_, { tab }) => tab,
},
],
},
actionToUrl: ({ values }) => ({
setTab: () => '/events' + (values.tab === 'live' ? '' : '/' + values.tab),
}),
urlToAction: ({ actions, values }) => ({
'/events(/:tab)': ({ tab }) => {
const currentTab = tab || 'live'
if (currentTab !== values.tab) {
actions.setTab(currentTab)
}
},
}),
})
export function ManageEvents(): JSX.Element {
const { tab } = useValues(eventsLogic)
const { setTab } = useActions(eventsLogic)
return (
<div data-attr="manage-events-table" style={{ paddingTop: 32 }}>
<Tabs tabPosition="top" animated={false} activeKey={tab} onTabClick={setTab}>
<Tabs.TabPane tab="Events" key="live">
<EventsTable />
</Tabs.TabPane>
<Tabs.TabPane tab={<span data-attr="events-actions-tab">Actions</span>} key="actions">
<ActionsTable />
</Tabs.TabPane>
<Tabs.TabPane tab="Events Stats" key="stats">
<EventsVolumeTable />
</Tabs.TabPane>
<Tabs.TabPane tab="Properties Stats" key="properties">
<PropertiesVolumeTable />
</Tabs.TabPane>
</Tabs>
<DefinitionDrawer />
</div>
)
}

View File

@ -27,6 +27,8 @@ import { Tooltip } from 'lib/components/Tooltip'
import { LabelledSwitch } from 'scenes/events/LabelledSwitch'
import clsx from 'clsx'
import { tableConfigLogic } from 'lib/components/ResizableTable/tableConfigLogic'
import { SceneExport } from 'scenes/sceneTypes'
import { EventsTab, EventsTabs } from 'scenes/events/EventsTabs'
dayjs.extend(LocalizedFormat)
dayjs.extend(relativeTime)
@ -43,7 +45,13 @@ interface EventsTable {
pageKey?: string
}
export function EventsTable({ fixedFilters, filtersEnabled = true, pageKey }: EventsTable): JSX.Element {
export const scene: SceneExport = {
component: EventsTable,
logic: eventsTableLogic,
paramsToProps: ({ params: { fixedFilters, pageKey } }) => ({ fixedFilters, key: pageKey }),
}
export function EventsTable({ fixedFilters, filtersEnabled = true, pageKey }: EventsTable = {}): JSX.Element {
const logic = eventsTableLogic({ fixedFilters, key: pageKey })
const {
@ -283,115 +291,120 @@ export function EventsTable({ fixedFilters, filtersEnabled = true, pageKey }: Ev
)
return (
<div className="events" data-attr="events-table">
<PageHeader
title="Events"
caption="See events being sent to this project in near real time."
style={{ marginTop: 0 }}
/>
<div data-attr="manage-events-table" style={{ paddingTop: 32 }}>
<EventsTabs tab={EventsTab.Events} />
<div className="events" data-attr="events-table">
<PageHeader
title="Events"
caption="See events being sent to this project in near real time."
style={{ marginTop: 0 }}
/>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12}>
<EventName
value={eventFilter}
onChange={(value: string) => {
setEventFilter(value || '')
}}
/>
</Col>
<Col span={24}>
{filtersEnabled ? <PropertyFilters pageKey={'EventsTable'} style={{ marginBottom: 0 }} /> : null}
</Col>
</Row>
<Row gutter={[16, 16]} justify="end">
<Col flex="1">
<LabelledSwitch
label={'Automatically load new events'}
enabled={automaticLoadEnabled}
onToggle={toggleAutomaticLoad}
align="right"
/>
</Col>
<Col flex="0">
{exportUrl && (
<Tooltip title="Export up to 10,000 latest events." placement="left">
<Button icon={<DownloadOutlined />} href={exportUrl}>
Export events
</Button>
</Tooltip>
)}
</Col>
{featureFlags[FEATURE_FLAGS.EVENT_COLUMN_CONFIG] && (
<Col flex="0">
<TableConfig
availableColumns={propertyNames}
immutableColumns={['event', 'person', 'when']}
defaultColumns={defaultColumns.map((e) => e.key || '')}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12}>
<EventName
value={eventFilter}
onChange={(value: string) => {
setEventFilter(value || '')
}}
/>
</Col>
)}
</Row>
<Col span={24}>
{filtersEnabled ? (
<PropertyFilters pageKey={'EventsTable'} style={{ marginBottom: 0 }} />
) : null}
</Col>
</Row>
<div>
<ResizableTable
dataSource={eventsFormatted}
loading={isLoading}
columns={columns}
size="small"
key={selectedColumns === 'DEFAULT' ? 'default' : selectedColumns.join('-')}
className="ph-no-capture"
locale={{
emptyText: isLoading ? (
<span>&nbsp;</span>
) : (
<span>
You don't have any items here! If you haven't integrated PostHog yet,{' '}
<Link to="/project/settings">click here to set PostHog up on your app</Link>.
</span>
),
}}
pagination={{ pageSize: 99999, hideOnSinglePage: true }}
rowKey={(row) =>
row.event ? row.event.id + '-' + row.event.event : row.date_break?.toString() || ''
}
rowClassName={(row) => {
return clsx({
'event-row': row.event,
'highlight-new-row': row.event && highlightEvents[(row.event as EventType).id],
'event-row-is-exception': row.event && row.event.event === '$exception',
'event-day-separator': row.date_break,
'event-row-new': row.new_events,
})
}}
expandable={{
expandedRowRender: function renderExpand({ event }) {
return event && <EventDetails event={event} />
},
rowExpandable: ({ event }) => !!event,
expandRowByClick: true,
}}
onRow={(row) => ({
onClick: () => {
if (row.new_events) {
prependNewEvents(newEvents)
}
},
})}
/>
<div
style={{
visibility: hasNext || isLoadingNext ? 'visible' : 'hidden',
margin: '2rem auto 5rem',
textAlign: 'center',
}}
>
<Button type="primary" onClick={fetchNextEvents}>
{isLoadingNext ? <Spin /> : 'Load more events'}
</Button>
<Row gutter={[16, 16]} justify="end">
<Col flex="1">
<LabelledSwitch
label={'Automatically load new events'}
enabled={automaticLoadEnabled}
onToggle={toggleAutomaticLoad}
align="right"
/>
</Col>
<Col flex="0">
{exportUrl && (
<Tooltip title="Export up to 10,000 latest events." placement="left">
<Button icon={<DownloadOutlined />} href={exportUrl}>
Export events
</Button>
</Tooltip>
)}
</Col>
{featureFlags[FEATURE_FLAGS.EVENT_COLUMN_CONFIG] && (
<Col flex="0">
<TableConfig
availableColumns={propertyNames}
immutableColumns={['event', 'person', 'when']}
defaultColumns={defaultColumns.map((e) => e.key || '')}
/>
</Col>
)}
</Row>
<div>
<ResizableTable
dataSource={eventsFormatted}
loading={isLoading}
columns={columns}
size="small"
key={selectedColumns === 'DEFAULT' ? 'default' : selectedColumns.join('-')}
className="ph-no-capture"
locale={{
emptyText: isLoading ? (
<span>&nbsp;</span>
) : (
<span>
You don't have any items here! If you haven't integrated PostHog yet,{' '}
<Link to="/project/settings">click here to set PostHog up on your app</Link>.
</span>
),
}}
pagination={{ pageSize: 99999, hideOnSinglePage: true }}
rowKey={(row) =>
row.event ? row.event.id + '-' + row.event.event : row.date_break?.toString() || ''
}
rowClassName={(row) => {
return clsx({
'event-row': row.event,
'highlight-new-row': row.event && highlightEvents[(row.event as EventType).id],
'event-row-is-exception': row.event && row.event.event === '$exception',
'event-day-separator': row.date_break,
'event-row-new': row.new_events,
})
}}
expandable={{
expandedRowRender: function renderExpand({ event }) {
return event && <EventDetails event={event} />
},
rowExpandable: ({ event }) => !!event,
expandRowByClick: true,
}}
onRow={(row) => ({
onClick: () => {
if (row.new_events) {
prependNewEvents(newEvents)
}
},
})}
/>
<div
style={{
visibility: hasNext || isLoadingNext ? 'visible' : 'hidden',
margin: '2rem auto 5rem',
textAlign: 'center',
}}
>
<Button type="primary" onClick={fetchNextEvents}>
{isLoadingNext ? <Spin /> : 'Load more events'}
</Button>
</div>
</div>
<div style={{ marginTop: '5rem' }} />
</div>
<div style={{ marginTop: '5rem' }} />
</div>
)
}

View File

@ -0,0 +1,60 @@
import React from 'react'
import { kea, useActions } from 'kea'
import { Tabs } from 'antd'
import { urls } from 'scenes/urls'
import { eventsTabsLogicType } from './EventsTabsType'
export enum EventsTab {
Events = 'events',
Actions = 'actions',
EventStats = 'stats',
EventPropertyStats = 'properties',
}
const tabUrls: Record<EventsTab, string> = {
[EventsTab.EventPropertyStats]: urls.eventPropertyStats(),
[EventsTab.EventStats]: urls.eventStats(),
[EventsTab.Actions]: urls.actions(),
[EventsTab.Events]: urls.events(),
}
const eventsTabsLogic = kea<eventsTabsLogicType<EventsTab>>({
actions: {
setTab: (tab: EventsTab) => ({ tab }),
},
reducers: {
tab: [
EventsTab.Events as EventsTab,
{
setTab: (_, { tab }) => tab,
},
],
},
actionToUrl: () => ({
setTab: ({ tab }) => tabUrls[tab as EventsTab] || urls.events(),
}),
urlToAction: ({ actions, values }) => {
return Object.fromEntries(
Object.entries(tabUrls).map(([key, url]) => [
url,
() => {
if (values.tab !== key) {
actions.setTab(key as EventsTab)
}
},
])
)
},
})
export function EventsTabs({ tab }: { tab: EventsTab }): JSX.Element {
const { setTab } = useActions(eventsTabsLogic)
return (
<Tabs tabPosition="top" animated={false} activeKey={tab} onTabClick={(t) => setTab(t as EventsTab)}>
<Tabs.TabPane tab="Events" key="events" />
<Tabs.TabPane tab={<span data-attr="events-actions-tab">Actions</span>} key="actions" />
<Tabs.TabPane tab="Events Stats" key="stats" />
<Tabs.TabPane tab="Properties Stats" key="properties" />
</Tabs>
)
}

View File

@ -6,13 +6,22 @@ import { PageHeader } from 'lib/components/PageHeader'
import { eventDefinitionsModel } from '~/models/eventDefinitionsModel'
import { UsageDisabledWarning } from './UsageDisabledWarning'
import { VolumeTable } from './VolumeTable'
import { DefinitionDrawer } from 'scenes/events/definitions/DefinitionDrawer'
import { SceneExport } from 'scenes/sceneTypes'
import { EventsTab, EventsTabs } from 'scenes/events/EventsTabs'
export const scene: SceneExport = {
component: EventsVolumeTable,
logic: eventDefinitionsModel,
}
export function EventsVolumeTable(): JSX.Element | null {
const { preflight } = useValues(preflightLogic)
const { eventDefinitions, loaded } = useValues(eventDefinitionsModel)
return (
<>
<div data-attr="manage-events-table" style={{ paddingTop: 32 }}>
<EventsTabs tab={EventsTab.EventStats} />
<PageHeader
title="Events Stats"
caption="See all event names that have ever been sent to this team, including the volume and how often queries where made using this event."
@ -37,6 +46,7 @@ export function EventsVolumeTable(): JSX.Element | null {
) : (
<Skeleton active paragraph={{ rows: 5 }} />
)}
</>
<DefinitionDrawer />
</div>
)
}

View File

@ -6,13 +6,23 @@ import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel'
import { PageHeader } from 'lib/components/PageHeader'
import { UsageDisabledWarning } from './UsageDisabledWarning'
import { VolumeTable } from './VolumeTable'
import { DefinitionDrawer } from 'scenes/events/definitions/DefinitionDrawer'
import { SceneExport } from 'scenes/sceneTypes'
import { eventDefinitionsModel } from '~/models/eventDefinitionsModel'
import { EventsTab, EventsTabs } from 'scenes/events/EventsTabs'
export const scene: SceneExport = {
component: PropertiesVolumeTable,
logic: eventDefinitionsModel,
}
export function PropertiesVolumeTable(): JSX.Element | null {
const { preflight } = useValues(preflightLogic)
const { propertyDefinitions, loaded } = useValues(propertyDefinitionsModel)
return (
<>
<div data-attr="manage-events-table" style={{ paddingTop: 32 }}>
<EventsTabs tab={EventsTab.EventPropertyStats} />
<PageHeader
title="Properties Stats"
caption="See all property keys that have ever been sent to this team, including the volume and how often
@ -39,6 +49,7 @@ export function PropertiesVolumeTable(): JSX.Element | null {
) : (
<Skeleton active paragraph={{ rows: 5 }} />
)}
</>
<DefinitionDrawer />
</div>
)
}

View File

@ -1,12 +1,11 @@
import { Meta } from '@storybook/react'
import { keaStory } from 'lib/storybook/kea-story'
import { ManageEvents } from '../Events'
import eventsState from './events.json'
import { EventsTable } from 'scenes/events'
export default {
title: 'PostHog/Scenes/Events',
} as Meta
export const AllEvents = keaStory(ManageEvents, eventsState)
export const AllEvents = keaStory(EventsTable, eventsState)

View File

@ -1,5 +1,5 @@
export * from './EventDetails'
export * from './Events'
export * from './EventsTabs'
export * from './EventElements'
export * from './EventsTable'
export * from './eventsTableLogic'

View File

@ -28,6 +28,12 @@ import { IconExternalLink, IconJavascript, IconPython } from 'lib/components/ico
import { teamLogic } from 'scenes/teamLogic'
import { Tooltip } from 'lib/components/Tooltip'
import { FEATURE_FLAGS } from 'lib/constants'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: FeatureFlag,
logic: featureFlagLogic,
}
const UTM_TAGS = '?utm_medium=in-product&utm_campaign=feature-flag'

View File

@ -17,6 +17,12 @@ import { urls } from 'scenes/urls'
import { Tooltip } from 'lib/components/Tooltip'
import stringWithWBR from 'lib/utils/stringWithWBR'
import { teamLogic } from '../teamLogic'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: FeatureFlags,
logic: featureFlagsLogic,
}
export function FeatureFlags(): JSX.Element {
const { currentTeamId } = useValues(teamLogic)

View File

@ -311,7 +311,7 @@ export const funnelLogic = kea<funnelLogicType>({
0
)
}
return people.sort((a, b) => score(b) - score(a))
return [...people].sort((a, b) => score(b) - score(a))
},
],
isStepsEmpty: [() => [selectors.filters], (filters: FilterType) => isStepsEmpty(filters)],

View File

@ -13,6 +13,12 @@ import { FrameworkPanel } from 'scenes/ingestion/panels/FrameworkPanel'
import { FrameworkGrid } from 'scenes/ingestion/panels/FrameworkGrid'
import { PlatformPanel } from 'scenes/ingestion/panels/PlatformPanel'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: IngestionWizard,
logic: ingestionLogic,
}
export function IngestionContainer({ children }: { children: React.ReactNode }): JSX.Element {
return (

View File

@ -127,7 +127,7 @@ describe('insightMetadataLogic', () => {
.toDispatchActions(insightLogic, [
insightLogic({ dashboardItemId: undefined }).actionCreators.setInsight(
{ name: insight.name },
true
{ shouldMergeWithExisting: true }
),
])
.toDispatchActions(logic, [

View File

@ -64,7 +64,10 @@ export const insightMetadataLogic = kea<insightMetadataLogicType<InsightMetadata
await actions.updateInsight({ [property]: values.insightMetadata[property] })
} else {
// Update local insight state
await actions.setInsight({ [property]: values.insightMetadata[property] }, true)
await actions.setInsight(
{ [property]: values.insightMetadata[property] },
{ shouldMergeWithExisting: true }
)
}
actions.setInsightMetadata({ [property]: values.insightMetadata[property] }) // sync
actions.showViewMode(property)

View File

@ -8,6 +8,7 @@ import React from 'react'
import { DashboardItemType } from '~/types'
import { teamLogic } from '../teamLogic'
import { insightRouterLogicType } from './InsightRouterType'
import { SceneExport } from 'scenes/sceneTypes'
const insightRouterLogic = kea<insightRouterLogicType>({
actions: {
@ -28,7 +29,7 @@ const insightRouterLogic = kea<insightRouterLogicType>({
if (response.results.length) {
const item = response.results[0] as DashboardItemType
eventUsageLogic.actions.reportInsightShortUrlVisited(true, item.filters.insight || null)
router.actions.push(
router.actions.replace(
combineUrl('/insights', item.filters, {
fromItem: item.id,
fromItemName: item.name,
@ -79,3 +80,8 @@ export function InsightRouter(): JSX.Element {
</>
)
}
export const scene: SceneExport = {
component: InsightRouter,
logic: insightRouterLogic,
}

View File

@ -23,9 +23,16 @@ import { InsightsNav } from './InsightsNav'
import { SaveToDashboard } from 'lib/components/SaveToDashboard/SaveToDashboard'
import { InsightContainer } from 'scenes/insights/InsightContainer'
import { InsightMetadata } from 'scenes/insights/InsightMetadata'
import { SceneExport } from 'scenes/sceneTypes'
dayjs.extend(relativeTime)
export const scene: SceneExport = {
component: Insights,
logic: insightLogic,
paramsToProps: ({ hashParams: { fromItem } }) => ({ dashboardItemId: fromItem, syncWithUrl: true }),
}
export function Insights(): JSX.Element {
useMountedLogic(insightCommandLogic)
const {
@ -81,7 +88,7 @@ export function Insights(): JSX.Element {
},
})
const scene = (
const insightScene = (
<div className="insights-page">
{featureFlags[FEATURE_FLAGS.SAVED_INSIGHTS] && insightMode === ItemMode.View ? (
<div className="insight-metadata">
@ -253,7 +260,7 @@ export function Insights(): JSX.Element {
return (
<BindLogic logic={insightLogic} props={insightProps}>
{scene}
{insightScene}
</BindLogic>
)
}

View File

@ -1,10 +1,13 @@
import { defaultAPIMocks, mockAPI, MOCK_TEAM_ID } from 'lib/api.mock'
import { expectLogic } from 'kea-test-utils'
import { expectLogic, partial } from 'kea-test-utils'
import { initKeaTestLogic } from '~/test/init'
import { insightLogic } from './insightLogic'
import { AvailableFeature, PropertyOperator, ViewType } from '~/types'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { combineUrl, router } from 'kea-router'
import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { FEATURE_FLAGS } from 'lib/constants'
jest.mock('lib/api')
@ -28,6 +31,14 @@ describe('insightLogic', () => {
properties: [{ value: 'a', operator: PropertyOperator.Exact, key: 'a', type: 'a' }],
},
}
} else if ([`api/projects/${MOCK_TEAM_ID}/dashboards/33/`].includes(pathname)) {
return {
id: 33,
filters: {},
items: [
{ id: 42, result: 'result!', filters: { insight: 'TRENDS', interval: 'month' }, tags: ['bla'] },
],
}
} else if ([`api/projects/${MOCK_TEAM_ID}/insights/44`].includes(pathname)) {
throwAPIError()
} else if (
@ -374,4 +385,33 @@ describe('insightLogic', () => {
})
})
})
describe('takes data from dashboardLogic if available', () => {
initKeaTestLogic()
it('works if all conditions match', async () => {
// 0. the feature flag must be set
featureFlagLogic.mount()
featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.TURBO_MODE], { [FEATURE_FLAGS.TURBO_MODE]: true })
// 1. the URL must have the dashboard and insight IDs
router.actions.push('/insights', {}, { fromDashboard: 33, fromItem: 42 })
// 2. the dashboard is mounted
const dashLogic = dashboardLogic({ id: 33 })
dashLogic.mount()
await expectLogic(dashLogic).toDispatchActions(['loadDashboardItemsSuccess'])
// 3. mount the insight
logic = insightLogic({ dashboardItemId: 42 })
logic.mount()
// 4. verify it didn't make any API calls
await expectLogic(logic)
.toDispatchActions(['setInsight'])
.toNotHaveDispatchedActions(['setFilters', 'loadResults', 'loadInsight', 'updateInsight'])
.toMatchValues({
insight: partial({ id: 42, result: 'result!', filters: { insight: 'TRENDS', interval: 'month' } }),
})
})
})
})

View File

@ -3,9 +3,17 @@ import { errorToast, objectsEqual, toParams, uuid } from 'lib/utils'
import posthog from 'posthog-js'
import { eventUsageLogic, InsightEventSource } from 'lib/utils/eventUsageLogic'
import { insightLogicType } from './insightLogicType'
import { DashboardItemType, FilterType, InsightLogicProps, InsightType, ItemMode, ViewType } from '~/types'
import {
DashboardItemType,
FilterType,
InsightLogicProps,
InsightType,
ItemMode,
SetInsightOptions,
ViewType,
} from '~/types'
import { captureInternalMetric } from 'lib/internalMetrics'
import { Scene, sceneLogic } from 'scenes/sceneLogic'
import { sceneLogic } from 'scenes/sceneLogic'
import { router } from 'kea-router'
import api from 'lib/api'
import { toast } from 'react-toastify'
@ -21,6 +29,8 @@ import { preflightLogic } from 'scenes/PreflightCheck/logic'
import { extractObjectDiffKeys } from './utils'
import * as Sentry from '@sentry/browser'
import { teamLogic } from '../teamLogic'
import { Scene } from 'scenes/sceneTypes'
import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
const IS_TEST_MODE = process.env.NODE_ENV === 'test'
@ -40,7 +50,7 @@ export const insightLogic = kea<insightLogicType>({
connect: {
values: [teamLogic, ['currentTeamId']],
logic: [eventUsageLogic, dashboardsModel],
logic: [eventUsageLogic, dashboardsModel, featureFlagLogic],
},
actions: () => ({
@ -70,9 +80,9 @@ export const insightLogic = kea<insightLogicType>({
toggleControlsCollapsed: true,
saveNewTag: (tag: string) => ({ tag }),
deleteTag: (tag: string) => ({ tag }),
setInsight: (insight: Partial<DashboardItemType>, shouldMergeWithExisting: boolean = false) => ({
setInsight: (insight: Partial<DashboardItemType>, options: SetInsightOptions = {}) => ({
insight,
shouldMergeWithExisting,
options,
}),
setInsightMode: (mode: ItemMode, source: InsightEventSource | null) => ({ mode, source }),
setInsightDescription: (description: string) => ({ description }),
@ -235,13 +245,10 @@ export const insightLogic = kea<insightLogicType>({
result: null,
filters: {},
},
setInsight: (state, { insight, shouldMergeWithExisting }) =>
shouldMergeWithExisting
? {
...state,
...insight,
}
: insight,
setInsight: (state, { insight, options: { shouldMergeWithExisting } }) => ({
...(shouldMergeWithExisting ? state : {}),
...insight,
}),
updateInsightFilters: (state, { filters }) => ({ ...state, filters }),
},
showTimeoutMessage: [false, { setShowTimeoutMessage: (_, { showTimeoutMessage }) => showTimeoutMessage }],
@ -283,6 +290,8 @@ export const insightLogic = kea<insightLogicType>({
() => props.filters || ({} as Partial<FilterType>),
{
setFilters: (state, { filters }) => cleanFilters(filters, state),
setInsight: (state, { insight: { filters }, options: { overrideFilter } }) =>
overrideFilter ? cleanFilters(filters || {}) : state,
loadInsightSuccess: (state, { insight }) =>
Object.keys(state).length === 0 && insight.filters ? insight.filters : state,
loadResultsSuccess: (state, { insight }) =>
@ -568,19 +577,39 @@ export const insightLogic = kea<insightLogicType>({
urlToAction: ({ actions, values, props }) => ({
'/insights': (_: any, searchParams: Record<string, any>, hashParams: Record<string, any>) => {
if (props.syncWithUrl) {
let loadedFromDashboard = false
if (searchParams.insight === 'HISTORY' || !hashParams.fromItem) {
if (values.insightMode !== ItemMode.Edit) {
actions.setInsightMode(ItemMode.Edit, null)
}
} else if (hashParams.fromItem) {
if (!values.insight?.id || values.insight?.id !== hashParams.fromItem) {
const insightIdChanged = !values.insight.id || values.insight.id !== hashParams.fromItem
if (
featureFlagLogic.values.featureFlags[FEATURE_FLAGS.TURBO_MODE] &&
hashParams.fromDashboard &&
(!values.insight.result || insightIdChanged)
) {
const logic = dashboardLogic.findMounted({ id: hashParams.fromDashboard })
if (logic) {
const insight = logic.values.allItems?.items?.find(
(item: DashboardItemType) => item.id === Number(hashParams.fromItem)
)
if (insight?.result) {
actions.setInsight(insight, { overrideFilter: true })
loadedFromDashboard = true
}
}
}
if (!loadedFromDashboard && insightIdChanged) {
// Do not load the result if missing, as setFilters below will do so anyway.
actions.loadInsight(hashParams.fromItem, { doNotLoadResults: true })
}
}
const cleanSearchParams = cleanFilters(searchParams, values.filters)
if (!objectsEqual(cleanSearchParams, values.filters)) {
if (!loadedFromDashboard && !objectsEqual(cleanSearchParams, values.filters)) {
actions.setFilters(cleanSearchParams)
}
}
@ -590,6 +619,18 @@ export const insightLogic = kea<insightLogicType>({
afterMount: () => {
if (!props.cachedResults) {
if (props.dashboardItemId && !props.filters) {
if (
featureFlagLogic.values.featureFlags[FEATURE_FLAGS.TURBO_MODE] &&
router.values.hashParams.fromItem === props.dashboardItemId &&
router.values.hashParams.fromDashboard
) {
const logic = dashboardLogic.findMounted({ id: router.values.hashParams.fromDashboard })
const insight = logic?.values.allItems?.items?.find((item) => item.id === props.dashboardItemId)
if (insight?.result) {
actions.setInsight(insight, { overrideFilter: true })
return
}
}
actions.loadInsight(props.dashboardItemId)
} else if (!props.doNotLoad) {
actions.loadResults()

View File

@ -7,6 +7,12 @@ import { CodeSnippet } from 'scenes/ingestion/frameworks/CodeSnippet'
import { PageHeader } from 'lib/components/PageHeader'
import { InfoCircleOutlined } from '@ant-design/icons'
import { Tooltip } from 'lib/components/Tooltip'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: Licenses,
logic: licenseLogic,
}
const columns = [
{

View File

@ -9,6 +9,12 @@ import { preflightLogic } from 'scenes/PreflightCheck/logic'
import { IconExternalLink } from 'lib/components/icons'
import { OverviewTab } from 'scenes/instance/SystemStatus/OverviewTab'
import { InternalMetricsTab } from 'scenes/instance/SystemStatus/InternalMetricsTab'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: SystemStatus,
logic: systemStatusLogic,
}
export function SystemStatus(): JSX.Element {
const { tab, error, systemStatus } = useValues(systemStatusLogic)

View File

@ -8,6 +8,11 @@ import { ChangePassword } from './ChangePassword'
import { PersonalAPIKeys } from 'lib/components/PersonalAPIKeys'
import { OptOutCapture } from './OptOutCapture'
import { PageHeader } from 'lib/components/PageHeader'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: MySettings,
}
export function MySettings(): JSX.Element {
const { location } = useValues(router)

View File

@ -23,9 +23,15 @@ import { LinkButton } from 'lib/components/LinkButton'
import { organizationLogic } from 'scenes/organizationLogic'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { teamLogic } from 'scenes/teamLogic'
import { SceneExport } from 'scenes/sceneTypes'
const { Panel } = Collapse
export const scene: SceneExport = {
component: OnboardingSetup,
logic: onboardingSetupLogic,
}
function PanelHeader({
title,
caption,

View File

@ -6,6 +6,12 @@ import { RadioSelect } from 'lib/components/RadioSelect'
import { ROLES, PRODUCTS, IS_TECHNICAL } from './personalizationOptions'
import { Link } from 'lib/components/Link'
import './Personalization.scss'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: Personalization,
logic: personalizationLogic,
}
export function Personalization(): JSX.Element {
const { personalizationData } = useValues(personalizationLogic)

View File

@ -1,5 +1,12 @@
import React from 'react'
import { CreateOrganizationModal } from '../CreateOrganizationModal'
import { SceneExport } from 'scenes/sceneTypes'
import { organizationLogic } from 'scenes/organizationLogic'
export const scene: SceneExport = {
component: Create,
logic: organizationLogic,
}
export function Create(): JSX.Element {
return <CreateOrganizationModal isVisible />

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react'
import { Button, Card, Input, Divider, Select, Skeleton, Switch } from 'antd'
import { UserType } from '~/types'
import { PageHeader } from 'lib/components/PageHeader'
import { Invites } from './Invites'
import { Members } from './Members'
@ -11,6 +10,13 @@ import { RestrictedArea, RestrictedComponentProps } from '../../../lib/component
import { OrganizationMembershipLevel } from '../../../lib/constants'
import { preflightLogic } from 'scenes/PreflightCheck/logic'
import { IconExternalLink } from 'lib/components/icons'
import { userLogic } from 'scenes/userLogic'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: OrganizationSettings,
logic: organizationLogic,
}
function DisplayName({ isRestricted }: RestrictedComponentProps): JSX.Element {
const { currentOrganization, currentOrganizationLoading } = useValues(organizationLogic)
@ -137,7 +143,8 @@ function EmailPreferences({ isRestricted }: RestrictedComponentProps): JSX.Eleme
)
}
export function OrganizationSettings({ user }: { user: UserType }): JSX.Element {
export function OrganizationSettings(): JSX.Element {
const { user } = useValues(userLogic)
const { preflight } = useValues(preflightLogic)
return (
@ -160,7 +167,7 @@ export function OrganizationSettings({ user }: { user: UserType }): JSX.Element
)}
<Invites />
<Divider />
<Members user={user} />
{user && <Members user={user} />}
<Divider />
<RestrictedArea Component={EmailPreferences} minimumAccessLevel={OrganizationMembershipLevel.Admin} />
<Divider />

View File

@ -29,10 +29,17 @@ import { PageHeader } from 'lib/components/PageHeader'
import { SplitPerson } from './SplitPerson'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { FEATURE_FLAGS } from 'lib/constants'
import { SceneExport } from 'scenes/sceneTypes'
dayjs.extend(relativeTime)
const { TabPane } = Tabs
export const scene: SceneExport = {
component: Person,
logic: personsLogic,
}
export function Person(): JSX.Element {
const [activeCardTab, setActiveCardTab] = useState('properties')
const [mergeModalOpen, setMergeModalOpen] = useState(false)

View File

@ -12,8 +12,18 @@ import { ClockCircleFilled } from '@ant-design/icons'
import { toParams } from 'lib/utils'
import { PersonsSearch } from './PersonsSearch'
import { IconExternalLink } from 'lib/components/icons'
import { SceneExport } from 'scenes/sceneTypes'
export function Persons({ cohort }: { cohort: CohortType }): JSX.Element {
export const scene: SceneExport = {
component: Persons,
logic: personsLogic,
}
interface PersonsProps {
cohort?: CohortType
}
export function Persons({ cohort }: PersonsProps = {}): JSX.Element {
const { loadPersons, setListFilters } = useActions(personsLogic)
const { persons, listFilters, personsLoading } = useValues(personsLogic)

View File

@ -10,20 +10,27 @@ import { PageHeader } from 'lib/components/PageHeader'
import { PluginTab } from 'scenes/plugins/types'
import { AdvancedTab } from 'scenes/plugins/tabs/advanced/AdvancedTab'
import { canGloballyManagePlugins, canInstallPlugins, canViewPlugins } from './access'
import { UserType } from '../../types'
import { userLogic } from 'scenes/userLogic'
import { SceneExport } from 'scenes/sceneTypes'
export function Plugins({ user }: { user: UserType }): JSX.Element | null {
export const scene: SceneExport = {
component: Plugins,
logic: pluginsLogic,
}
export function Plugins(): JSX.Element | null {
const { user } = useValues(userLogic)
const { pluginTab } = useValues(pluginsLogic)
const { setPluginTab } = useActions(pluginsLogic)
const { TabPane } = Tabs
useEffect(() => {
if (!canViewPlugins(user.organization)) {
if (!canViewPlugins(user?.organization)) {
window.location.href = '/'
}
}, [user])
if (!canViewPlugins(user.organization)) {
if (!user || !canViewPlugins(user?.organization)) {
return null
}

View File

@ -1,5 +1,10 @@
import React from 'react'
import { CreateProjectModal } from '../CreateProjectModal'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: Create,
}
export function Create(): JSX.Element {
return <CreateProjectModal isVisible />

View File

@ -23,12 +23,17 @@ import { TestAccountFiltersConfig } from './TestAccountFiltersConfig'
import { TimezoneConfig } from './TimezoneConfig'
import { DataAttributes } from 'scenes/project/Settings/DataAttributes'
import { featureFlagLogic } from '../../../lib/logic/featureFlagLogic'
import { AvailableFeature, UserType } from '../../../types'
import { AvailableFeature } from '../../../types'
import { TeamMembers } from './TeamMembers'
import { teamMembersLogic } from './teamMembersLogic'
import { AccessControl } from './AccessControl'
import { PathCleaningFiltersConfig } from './PathCleaningFiltersConfig'
import { userLogic } from 'scenes/userLogic'
import { SceneExport } from 'scenes/sceneTypes'
export const scene: SceneExport = {
component: ProjectSettings,
}
function DisplayName(): JSX.Element {
const { currentTeam, currentTeamLoading } = useValues(teamLogic)
@ -69,12 +74,12 @@ function DisplayName(): JSX.Element {
)
}
export function ProjectSettings({ user }: { user: UserType }): JSX.Element {
export function ProjectSettings(): JSX.Element {
const { currentTeam, currentTeamLoading } = useValues(teamLogic)
const { resetToken } = useActions(teamLogic)
const { location } = useValues(router)
const { featureFlags } = useValues(featureFlagLogic)
const { hasAvailableFeature } = useValues(userLogic)
const { user, hasAvailableFeature } = useValues(userLogic)
useAnchor(location.hash)
@ -264,7 +269,7 @@ export function ProjectSettings({ user }: { user: UserType }): JSX.Element {
<Divider />
{currentTeam?.access_control && hasAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING) && (
<BindLogic logic={teamMembersLogic} props={{ team: currentTeam }}>
<TeamMembers user={user} team={currentTeam} />
{user && <TeamMembers user={user} team={currentTeam} />}
<Divider />
</BindLogic>
)}

View File

@ -36,6 +36,7 @@ import dayjs from 'dayjs'
import { PageHeader } from 'lib/components/PageHeader'
import { SavedInsightsEmptyState } from 'scenes/insights/EmptyStates'
import { teamLogic } from '../teamLogic'
import { SceneExport } from 'scenes/sceneTypes'
const { TabPane } = Tabs
@ -49,6 +50,11 @@ export interface InsightItem {
description: string
}
export const scene: SceneExport = {
component: SavedInsights,
logic: savedInsightsLogic,
}
export function SavedInsights(): JSX.Element {
const {
loadInsights,

View File

@ -0,0 +1,82 @@
import { sceneLogic } from './sceneLogic'
import { initKeaTests } from '~/test/init'
import { expectLogic, partial, truth } from 'kea-test-utils'
import { LoadedScene, Scene } from 'scenes/sceneTypes'
import { teamLogic } from 'scenes/teamLogic'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { defaultAPIMocks, mockAPI } from 'lib/api.mock'
import { router } from 'kea-router'
import { urls } from 'scenes/urls'
jest.mock('lib/api')
const expectedAnnotation = partial({
name: Scene.Annotations,
component: expect.any(Function),
logic: expect.any(Function),
sceneParams: { hashParams: {}, params: {}, searchParams: {} },
lastTouch: expect.any(Number),
})
const expectedDashboard = partial({
name: Scene.Dashboard,
component: expect.any(Function),
logic: expect.any(Function),
sceneParams: { hashParams: {}, params: { id: '123' }, searchParams: {} },
lastTouch: expect.any(Number),
})
describe('sceneLogic', () => {
let logic: ReturnType<typeof sceneLogic.build>
mockAPI(defaultAPIMocks)
beforeEach(async () => {
initKeaTests()
teamLogic.mount()
await expectLogic(teamLogic).toDispatchActions(['loadCurrentTeamSuccess'])
featureFlagLogic.mount()
router.actions.push(urls.annotations())
logic = sceneLogic()
logic.mount()
})
it('has preloaded some scenes', async () => {
const preloadedScenes = [Scene.Error404, Scene.ErrorNetwork, Scene.ErrorProjectUnavailable]
await expectLogic(logic).toMatchValues({
loadedScenes: truth(
(obj: Record<string, LoadedScene>) =>
Object.keys(obj).filter((key) => preloadedScenes.includes(key as Scene)).length === 3
),
})
})
it('changing URL runs openScene, loadScene and setScene', async () => {
await expectLogic(logic).toDispatchActions(['openScene', 'loadScene', 'setScene']).toMatchValues({
scene: Scene.Annotations,
})
router.actions.push(urls.dashboard(123))
await expectLogic(logic).toDispatchActions(['openScene', 'loadScene', 'setScene']).toMatchValues({
scene: Scene.Dashboard,
})
})
it('persists the loaded scenes', async () => {
await expectLogic(logic)
.delay(1)
.toMatchValues({
loadedScenes: partial({
[Scene.Annotations]: expectedAnnotation,
}),
})
router.actions.push(urls.dashboard(123))
await expectLogic(logic)
.delay(1)
.toMatchValues({
loadedScenes: partial({
[Scene.Annotations]: expectedAnnotation,
[Scene.Dashboard]: expectedDashboard,
}),
})
})
})

View File

@ -1,8 +1,6 @@
import { kea, LogicWrapper } from 'kea'
import { kea } from 'kea'
import { router } from 'kea-router'
import { identifierToHuman, delay } from 'lib/utils'
import { Error404 as Error404Component } from '~/layout/Error404'
import { ErrorNetwork as ErrorNetworkComponent } from '~/layout/ErrorNetwork'
import { identifierToHuman } from 'lib/utils'
import posthog from 'posthog-js'
import { sceneLogicType } from './sceneLogicType'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
@ -10,293 +8,25 @@ import { preflightLogic } from './PreflightCheck/logic'
import { AvailableFeature } from '~/types'
import { userLogic } from './userLogic'
import { afterLoginRedirect } from './authentication/loginLogic'
import { ErrorProjectUnavailable as ErrorProjectUnavailableComponent } from '../layout/ErrorProjectUnavailable'
import { teamLogic } from './teamLogic'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { urls } from 'scenes/urls'
import { SceneExport, Params, Scene, SceneConfig, SceneParams, LoadedScene } from 'scenes/sceneTypes'
import { emptySceneParams, preloadedScenes, redirects, routes, sceneConfigurations, scenes } from 'scenes/scenes'
import { FEATURE_FLAGS } from 'lib/constants'
export enum Scene {
Error404 = '404',
ErrorNetwork = '4xx',
ErrorProjectUnavailable = 'projectUnavailable',
Dashboards = 'dashboards',
Dashboard = 'dashboard',
Insights = 'insights',
InsightRouter = 'insightRouter',
Cohorts = 'cohorts',
Events = 'events',
Sessions = 'sessions',
SessionRecordings = 'sessionRecordings',
Person = 'person',
Persons = 'persons',
Action = 'action',
FeatureFlags = 'featureFlags',
FeatureFlag = 'featureFlag',
OrganizationSettings = 'organizationSettings',
OrganizationCreateFirst = 'organizationCreateFirst',
ProjectSettings = 'projectSettings',
ProjectCreateFirst = 'projectCreateFirst',
SystemStatus = 'systemStatus',
InstanceLicenses = 'instanceLicenses',
MySettings = 'mySettings',
Annotations = 'annotations',
Billing = 'billing',
Plugins = 'plugins',
SavedInsights = 'savedInsights',
// Authentication & onboarding routes
Login = 'login',
Signup = 'signup',
InviteSignup = 'inviteSignup',
PasswordReset = 'passwordReset',
PasswordResetComplete = 'passwordResetComplete',
PreflightCheck = 'preflightCheck',
Ingestion = 'ingestion',
OnboardingSetup = 'onboardingSetup',
Personalization = 'personalization',
}
const preloadedScenes: Record<string, LoadedScene> = {
[Scene.Error404]: {
component: Error404Component,
},
[Scene.ErrorNetwork]: {
component: ErrorNetworkComponent,
},
[Scene.ErrorProjectUnavailable]: {
component: ErrorProjectUnavailableComponent,
},
}
export const scenes: Record<Scene, () => any> = {
[Scene.Error404]: () => ({ default: preloadedScenes[Scene.Error404].component }),
[Scene.ErrorNetwork]: () => ({ default: preloadedScenes[Scene.ErrorNetwork].component }),
[Scene.ErrorProjectUnavailable]: () => ({ default: preloadedScenes[Scene.ErrorProjectUnavailable].component }),
[Scene.Dashboards]: () => import(/* webpackChunkName: 'dashboards' */ './dashboard/Dashboards'),
[Scene.Dashboard]: () => import(/* webpackChunkName: 'dashboard' */ './dashboard/Dashboard'),
[Scene.Insights]: () => import(/* webpackChunkName: 'insights' */ './insights/Insights'),
[Scene.InsightRouter]: () => import(/* webpackChunkName: 'insightRouter' */ './insights/InsightRouter'),
[Scene.Cohorts]: () => import(/* webpackChunkName: 'cohorts' */ './cohorts/Cohorts'),
[Scene.Events]: () => import(/* webpackChunkName: 'events' */ './events/Events'),
[Scene.Sessions]: () => import(/* webpackChunkName: 'sessions' */ './sessions/Sessions'),
[Scene.SessionRecordings]: () =>
import(/* webpackChunkName: 'sessionRecordings' */ './session-recordings/SessionRecordings'),
[Scene.Person]: () => import(/* webpackChunkName: 'person' */ './persons/Person'),
[Scene.Persons]: () => import(/* webpackChunkName: 'persons' */ './persons/Persons'),
[Scene.Action]: () => import(/* webpackChunkName: 'action' */ './actions/Action'),
[Scene.FeatureFlags]: () => import(/* webpackChunkName: 'featureFlags' */ './experimentation/FeatureFlags'),
[Scene.FeatureFlag]: () => import(/* webpackChunkName: 'featureFlag' */ './experimentation/FeatureFlag'),
[Scene.OrganizationSettings]: () =>
import(/* webpackChunkName: 'organizationSettings' */ './organization/Settings'),
[Scene.OrganizationCreateFirst]: () =>
import(/* webpackChunkName: 'organizationCreateFirst' */ './organization/Create'),
[Scene.ProjectSettings]: () => import(/* webpackChunkName: 'projectSettings' */ './project/Settings'),
[Scene.ProjectCreateFirst]: () => import(/* webpackChunkName: 'projectCreateFirst' */ './project/Create'),
[Scene.SystemStatus]: () => import(/* webpackChunkName: 'systemStatus' */ './instance/SystemStatus'),
[Scene.InstanceLicenses]: () => import(/* webpackChunkName: 'instanceLicenses' */ './instance/Licenses'),
[Scene.MySettings]: () => import(/* webpackChunkName: 'mySettings' */ './me/Settings'),
[Scene.Annotations]: () => import(/* webpackChunkName: 'annotations' */ './annotations'),
[Scene.PreflightCheck]: () => import(/* webpackChunkName: 'preflightCheck' */ './PreflightCheck'),
[Scene.Signup]: () => import(/* webpackChunkName: 'signup' */ './authentication/Signup'),
[Scene.InviteSignup]: () => import(/* webpackChunkName: 'inviteSignup' */ './authentication/InviteSignup'),
[Scene.Ingestion]: () => import(/* webpackChunkName: 'ingestion' */ './ingestion/IngestionWizard'),
[Scene.Billing]: () => import(/* webpackChunkName: 'billing' */ './billing/Billing'),
[Scene.Plugins]: () => import(/* webpackChunkName: 'plugins' */ './plugins/Plugins'),
[Scene.Personalization]: () => import(/* webpackChunkName: 'personalization' */ './onboarding/Personalization'),
[Scene.OnboardingSetup]: () => import(/* webpackChunkName: 'onboardingSetup' */ './onboarding/OnboardingSetup'),
[Scene.Login]: () => import(/* webpackChunkName: 'login' */ './authentication/Login'),
[Scene.SavedInsights]: () => import(/* webpackChunkName: 'savedInsights' */ './saved-insights/SavedInsights'),
[Scene.PasswordReset]: () => import(/* webpackChunkName: 'passwordReset' */ './authentication/PasswordReset'),
[Scene.PasswordResetComplete]: () =>
import(/* webpackChunkName: 'passwordResetComplete' */ './authentication/PasswordResetComplete'),
}
interface LoadedScene {
component: () => JSX.Element
logic?: LogicWrapper
}
interface Params {
[param: string]: any
}
interface SceneConfig {
/** Route should only be accessed when logged out (N.B. should be added to posthog/urls.py too) */
onlyUnauthenticated?: boolean
/** Route **can** be accessed when logged out (i.e. can be accessed when logged in too; should be added to posthog/urls.py too) */
allowUnauthenticated?: boolean
/** Background is $bg_mid */
dark?: boolean
/** Only keeps the main content and the top navigation bar */
plain?: boolean
/** Hides the top navigation bar (regardless of whether `plain` is `true` or not) */
hideTopNav?: boolean
/** Hides demo project warnings (DemoWarning.tsx) */
hideDemoWarnings?: boolean
/** Route requires project access */
projectBased?: boolean
}
export const sceneConfigurations: Partial<Record<Scene, SceneConfig>> = {
// Project-based routes
[Scene.Dashboards]: {
projectBased: true,
},
[Scene.Dashboard]: {
projectBased: true,
},
[Scene.Insights]: {
projectBased: true,
dark: true,
},
[Scene.Cohorts]: {
projectBased: true,
},
[Scene.Events]: {
projectBased: true,
},
[Scene.Sessions]: {
projectBased: true,
},
[Scene.SessionRecordings]: {
projectBased: true,
},
[Scene.Person]: {
projectBased: true,
},
[Scene.Persons]: {
projectBased: true,
},
[Scene.Action]: {
projectBased: true,
},
[Scene.FeatureFlags]: {
projectBased: true,
},
[Scene.FeatureFlag]: {
projectBased: true,
},
[Scene.Annotations]: {
projectBased: true,
},
[Scene.Plugins]: {
projectBased: true,
},
[Scene.SavedInsights]: {
projectBased: true,
},
[Scene.ProjectSettings]: {
projectBased: true,
hideDemoWarnings: true,
},
[Scene.InsightRouter]: {
projectBased: true,
dark: true,
},
[Scene.Personalization]: {
projectBased: true,
plain: true,
hideTopNav: true,
},
[Scene.Ingestion]: {
projectBased: true,
plain: true,
},
[Scene.OnboardingSetup]: {
projectBased: true,
hideDemoWarnings: true,
},
// Organization-based routes
[Scene.OrganizationCreateFirst]: {
plain: true,
},
[Scene.ProjectCreateFirst]: {
plain: true,
},
[Scene.Billing]: {
hideDemoWarnings: true,
},
// Onboarding/setup routes
[Scene.Login]: {
onlyUnauthenticated: true,
},
[Scene.Signup]: {
onlyUnauthenticated: true,
},
[Scene.PreflightCheck]: {
onlyUnauthenticated: true,
},
[Scene.PasswordReset]: {
allowUnauthenticated: true,
},
[Scene.PasswordResetComplete]: {
allowUnauthenticated: true,
},
[Scene.InviteSignup]: {
allowUnauthenticated: true,
plain: true,
},
}
export const redirects: Record<string, string | ((params: Params) => string)> = {
'/': '/insights',
'/dashboards': '/dashboard', // TODO: For consistency this should be the default, but we should make sure /dashboard keeps working
'/plugins': '/project/plugins',
'/actions': '/events/actions',
'/organization/members': '/organization/settings',
}
export const routes: Record<string, Scene> = {
[urls.dashboards()]: Scene.Dashboards,
[urls.dashboard(':id')]: Scene.Dashboard,
[urls.createAction()]: Scene.Action,
[urls.action(':id')]: Scene.Action,
[urls.insights()]: Scene.Insights,
[urls.insightRouter(':id')]: Scene.InsightRouter,
[urls.events()]: Scene.Events,
[urls.events() + '/*']: Scene.Events,
[urls.sessions()]: Scene.Sessions,
[urls.sessionRecordings()]: Scene.SessionRecordings,
[urls.person('*')]: Scene.Person,
[urls.persons()]: Scene.Persons,
[urls.cohort(':id')]: Scene.Cohorts,
[urls.cohorts()]: Scene.Cohorts,
[urls.featureFlags()]: Scene.FeatureFlags,
[urls.featureFlag(':id')]: Scene.FeatureFlag,
[urls.annotations()]: Scene.Annotations,
[urls.projectSettings()]: Scene.ProjectSettings,
[urls.plugins()]: Scene.Plugins,
[urls.projectCreateFirst()]: Scene.ProjectCreateFirst,
[urls.organizationSettings()]: Scene.OrganizationSettings,
[urls.organizationBilling()]: Scene.Billing,
[urls.organizationCreateFirst()]: Scene.OrganizationCreateFirst,
[urls.instanceLicenses()]: Scene.InstanceLicenses,
[urls.systemStatus()]: Scene.SystemStatus,
[urls.systemStatusPage(':id')]: Scene.SystemStatus,
[urls.mySettings()]: Scene.MySettings,
[urls.savedInsights()]: Scene.SavedInsights,
// Onboarding / setup routes
[urls.login()]: Scene.Login,
[urls.preflight()]: Scene.PreflightCheck,
[urls.signup()]: Scene.Signup,
[urls.inviteSignup(':id')]: Scene.InviteSignup,
[urls.passwordReset()]: Scene.PasswordReset,
[urls.passwordResetComplete(':uuid', ':token')]: Scene.PasswordResetComplete,
[urls.personalization()]: Scene.Personalization,
[urls.ingestion()]: Scene.Ingestion,
[urls.ingestion() + '/*']: Scene.Ingestion,
[urls.onboardingSetup()]: Scene.OnboardingSetup,
}
export const sceneLogic = kea<sceneLogicType<LoadedScene, Params, Scene, SceneConfig>>({
export const sceneLogic = kea<sceneLogicType>({
actions: {
/* 1. Prepares to open the scene, as the listener may override and do something
else (e.g. redirecting if unauthenticated), then calls (2) `loadScene`*/
openScene: (scene: Scene, params: Params) => ({ scene, params }),
openScene: (scene: Scene, params: SceneParams) => ({ scene, params }),
// 2. Start loading the scene's Javascript and mount any logic, then calls (3) `setScene`
loadScene: (scene: Scene, params: Params) => ({ scene, params }),
loadScene: (scene: Scene, params: SceneParams) => ({ scene, params }),
// 3. Set the `scene` reducer
setScene: (scene: Scene, params: Params) => ({ scene, params }),
setLoadedScene: (scene: Scene, loadedScene: LoadedScene) => ({ scene, loadedScene }),
setScene: (scene: Scene, params: SceneParams) => ({ scene, params }),
setLoadedScene: (loadedScene: LoadedScene) => ({
loadedScene,
}),
showUpgradeModal: (featureName: string, featureCaption: string) => ({ featureName, featureCaption }),
guardAvailableFeature: (
featureKey: AvailableFeature,
@ -322,16 +52,20 @@ export const sceneLogic = kea<sceneLogicType<LoadedScene, Params, Scene, SceneCo
setScene: (_, payload) => payload.scene,
},
],
params: [
{} as Params,
{
setScene: (_, payload) => payload.params || {},
},
],
loadedScenes: [
preloadedScenes,
{
setLoadedScene: (state, { scene, loadedScene }) => ({ ...state, [scene]: loadedScene }),
setScene: (state, { scene, params }) =>
scene in state
? {
...state,
[scene]: { ...state[scene], sceneParams: params, lastTouch: new Date().valueOf() },
}
: state,
setLoadedScene: (state, { loadedScene }) => ({
...state,
[loadedScene.name]: { ...loadedScene, lastTouch: new Date().valueOf() },
}),
},
],
loadingScene: [
@ -352,28 +86,40 @@ export const sceneLogic = kea<sceneLogicType<LoadedScene, Params, Scene, SceneCo
},
selectors: {
sceneConfig: [
(selectors) => [selectors.scene],
(s) => [s.scene],
(scene: Scene): SceneConfig => {
return sceneConfigurations[scene] ?? {}
},
],
activeScene: [
(selectors) => [
selectors.loadingScene,
selectors.scene,
(s) => [
s.loadingScene,
s.scene,
teamLogic.selectors.isCurrentTeamUnavailable,
featureFlagLogic.selectors.featureFlags,
],
(loadingScene, scene, isCurrentTeamUnavailable) => {
const baseActiveScene = loadingScene || scene
(loadingScene, scene, isCurrentTeamUnavailable, featureFlags) => {
const baseActiveScene = featureFlags[FEATURE_FLAGS.TURBO_MODE] ? scene : loadingScene || scene
return isCurrentTeamUnavailable && baseActiveScene && sceneConfigurations[baseActiveScene]?.projectBased
? Scene.ErrorProjectUnavailable
: baseActiveScene
},
],
activeLoadedScene: [
(s) => [s.activeScene, s.loadedScenes],
(activeScene, loadedScenes) => (activeScene ? loadedScenes[activeScene] : null),
],
sceneParams: [
(s) => [s.activeLoadedScene],
(activeLoadedScene): SceneParams =>
activeLoadedScene?.sceneParams || { params: {}, searchParams: {}, hashParams: {} },
],
params: [(s) => [s.sceneParams], (sceneParams): Record<string, string> => sceneParams.params || {}],
searchParams: [(s) => [s.sceneParams], (sceneParams): Record<string, any> => sceneParams.searchParams || {}],
hashParams: [(s) => [s.sceneParams], (sceneParams): Record<string, any> => sceneParams.hashParams || {}],
},
urlToAction: ({ actions }) => {
const mapping: Record<string, (params: Params) => any> = {}
const mapping: Record<string, (params: Params, searchParams: Params, hashParams: Params) => any> = {}
for (const path of Object.keys(redirects)) {
mapping[path] = (params) => {
@ -382,10 +128,11 @@ export const sceneLogic = kea<sceneLogicType<LoadedScene, Params, Scene, SceneCo
}
}
for (const [path, scene] of Object.entries(routes)) {
mapping[path] = (params) => actions.openScene(scene, params)
mapping[path] = (params, searchParams, hashParams) =>
actions.openScene(scene, { params, searchParams, hashParams })
}
mapping['/*'] = () => actions.loadScene(Scene.Error404, {})
mapping['/*'] = () => actions.loadScene(Scene.Error404, emptySceneParams)
return mapping
},
@ -472,23 +219,14 @@ export const sceneLogic = kea<sceneLogicType<LoadedScene, Params, Scene, SceneCo
actions.loadScene(scene, params)
},
loadScene: async (
{
scene,
params = {},
}: {
scene: Scene
params: Params
},
breakpoint
) => {
loadScene: async ({ scene, params }, breakpoint) => {
if (values.scene === scene) {
actions.setScene(scene, params)
return
}
if (!scenes[scene]) {
actions.setScene(Scene.Error404, {})
actions.setScene(Scene.Error404, emptySceneParams)
return
}
@ -508,58 +246,56 @@ export const sceneLogic = kea<sceneLogicType<LoadedScene, Params, Scene, SceneCo
} else {
// First scene, show an error page
console.error('App assets regenerated. Showing error page.')
actions.setScene(Scene.ErrorNetwork, {})
actions.setScene(Scene.ErrorNetwork, emptySceneParams)
}
} else {
throw error
}
}
breakpoint()
const { default: defaultExport, logic, ...others } = importedScene
const { default: defaultExport, logic, scene: _scene, ...others } = importedScene
if (defaultExport) {
if (_scene) {
loadedScene = { name: scene, ...(_scene as SceneExport), sceneParams: params }
} else if (defaultExport) {
console.warn(`Scene ${scene} not yet converted to use SceneExport!`)
loadedScene = {
name: scene,
component: defaultExport,
logic: logic,
sceneParams: params,
}
} else {
console.warn(`Scene ${scene} not yet converted to use SceneExport!`)
loadedScene = {
name: scene,
component:
Object.keys(others).length === 1
? others[Object.keys(others)[0]]
: values.loadedScenes[Scene.Error404].component,
logic: logic,
sceneParams: params,
}
if (Object.keys(others).length > 1) {
console.error('There are multiple exports for this scene. Showing 404 instead.')
}
}
actions.setLoadedScene(scene, loadedScene)
}
const { logic } = loadedScene
actions.setLoadedScene(loadedScene)
let unmount
if (logic) {
// initialize the logic
unmount = logic.build(params, false).mount()
try {
await breakpoint(100)
} catch (e) {
// if we change the scene while waiting these 100ms, unmount
unmount()
throw e
let unmount
if (featureFlagLogic.values.featureFlags[FEATURE_FLAGS.TURBO_MODE] && loadedScene.logic) {
// initialize the logic and give it 50ms to load before opening the scene
unmount = loadedScene.logic.build(loadedScene.paramsToProps?.(params) || {}, false).mount()
try {
await breakpoint(50)
} catch (e) {
// if we change the scene while waiting these 50ms, unmount
unmount()
throw e
}
}
}
actions.setScene(scene, params)
if (unmount) {
// release our hold on this logic after 0.5s as it's by then surely mounted via React
// or we are anyway in a new scene and don't need it
await delay(500)
unmount()
}
},
setPageTitle: ({ title }) => {
document.title = title ? `${title} • PostHog` : 'PostHog'

View File

@ -0,0 +1,91 @@
import { LogicWrapper } from 'kea'
export enum Scene {
Error404 = '404',
ErrorNetwork = '4xx',
ErrorProjectUnavailable = 'projectUnavailable',
Dashboards = 'dashboards',
Dashboard = 'dashboard',
Insights = 'insights',
InsightRouter = 'insightRouter',
Cohorts = 'cohorts',
Events = 'events',
EventStats = 'eventStats',
EventPropertyStats = 'eventPropertyStats',
Sessions = 'sessions',
SessionRecordings = 'sessionRecordings',
Person = 'person',
Persons = 'persons',
Action = 'action',
Actions = 'actions',
FeatureFlags = 'featureFlags',
FeatureFlag = 'featureFlag',
OrganizationSettings = 'organizationSettings',
OrganizationCreateFirst = 'organizationCreateFirst',
ProjectSettings = 'projectSettings',
ProjectCreateFirst = 'projectCreateFirst',
SystemStatus = 'systemStatus',
InstanceLicenses = 'instanceLicenses',
MySettings = 'mySettings',
Annotations = 'annotations',
Billing = 'billing',
Plugins = 'plugins',
SavedInsights = 'savedInsights',
// Authentication & onboarding routes
Login = 'login',
Signup = 'signup',
InviteSignup = 'inviteSignup',
PasswordReset = 'passwordReset',
PasswordResetComplete = 'passwordResetComplete',
PreflightCheck = 'preflightCheck',
Ingestion = 'ingestion',
OnboardingSetup = 'onboardingSetup',
Personalization = 'personalization',
}
export type SceneProps = Record<string, any>
export type SceneComponent = (params?: SceneProps) => JSX.Element | null
export interface SceneExport {
/** component to render for this scene */
component: SceneComponent
/** logic to mount for this scene */
logic?: LogicWrapper
/** convert URL parameters from scenes.ts into logic props */
paramsToProps?: (params: SceneParams) => SceneProps
/** when was the scene last touched, unix timestamp for sortability */
lastTouch?: number
}
export interface LoadedScene extends SceneExport {
name: string
sceneParams: SceneParams
}
export interface SceneParams {
params: Record<string, any>
searchParams: Record<string, any>
hashParams: Record<string, any>
}
export interface Params {
[param: string]: any
}
export interface SceneConfig {
/** Route should only be accessed when logged out (N.B. should be added to posthog/urls.py too) */
onlyUnauthenticated?: boolean
/** Route **can** be accessed when logged out (i.e. can be accessed when logged in too; should be added to posthog/urls.py too) */
allowUnauthenticated?: boolean
/** Background is $bg_mid */
dark?: boolean
/** Only keeps the main content and the top navigation bar */
plain?: boolean
/** Hides the top navigation bar (regardless of whether `plain` is `true` or not) */
hideTopNav?: boolean
/** Hides demo project warnings (DemoWarning.tsx) */
hideDemoWarnings?: boolean
/** Route requires project access */
projectBased?: boolean
}

View File

@ -0,0 +1,226 @@
import { Params, Scene, SceneConfig, LoadedScene } from 'scenes/sceneTypes'
import { Error404 as Error404Component } from '~/layout/Error404'
import { ErrorNetwork as ErrorNetworkComponent } from '~/layout/ErrorNetwork'
import { ErrorProjectUnavailable as ErrorProjectUnavailableComponent } from '~/layout/ErrorProjectUnavailable'
import { urls } from 'scenes/urls'
export const emptySceneParams = { params: {}, searchParams: {}, hashParams: {} }
export const preloadedScenes: Record<string, LoadedScene> = {
[Scene.Error404]: {
name: Scene.Error404,
component: Error404Component,
sceneParams: emptySceneParams,
},
[Scene.ErrorNetwork]: {
name: Scene.ErrorNetwork,
component: ErrorNetworkComponent,
sceneParams: emptySceneParams,
},
[Scene.ErrorProjectUnavailable]: {
name: Scene.ErrorProjectUnavailable,
component: ErrorProjectUnavailableComponent,
sceneParams: emptySceneParams,
},
}
export const scenes: Record<Scene, () => any> = {
[Scene.Error404]: () => ({ default: preloadedScenes[Scene.Error404].component }),
[Scene.ErrorNetwork]: () => ({ default: preloadedScenes[Scene.ErrorNetwork].component }),
[Scene.ErrorProjectUnavailable]: () => ({ default: preloadedScenes[Scene.ErrorProjectUnavailable].component }),
[Scene.Dashboards]: () => import(/* webpackChunkName: 'dashboards' */ './dashboard/Dashboards'),
[Scene.Dashboard]: () => import(/* webpackChunkName: 'dashboard' */ './dashboard/Dashboard'),
[Scene.Insights]: () => import(/* webpackChunkName: 'insights' */ './insights/Insights'),
[Scene.InsightRouter]: () => import(/* webpackChunkName: 'insightRouter' */ './insights/InsightRouter'),
[Scene.Cohorts]: () => import(/* webpackChunkName: 'cohorts' */ './cohorts/Cohorts'),
[Scene.Events]: () => import(/* webpackChunkName: 'events' */ './events/EventsTable'),
[Scene.Actions]: () => import(/* webpackChunkName: 'events' */ './actions/ActionsTable'),
[Scene.EventStats]: () => import(/* webpackChunkName: 'events' */ './events/EventsVolumeTable'),
[Scene.EventPropertyStats]: () => import(/* webpackChunkName: 'events' */ './events/PropertiesVolumeTable'),
[Scene.Sessions]: () => import(/* webpackChunkName: 'sessions' */ './sessions/Sessions'),
[Scene.SessionRecordings]: () =>
import(/* webpackChunkName: 'sessionRecordings' */ './session-recordings/SessionRecordings'),
[Scene.Person]: () => import(/* webpackChunkName: 'person' */ './persons/Person'),
[Scene.Persons]: () => import(/* webpackChunkName: 'persons' */ './persons/Persons'),
[Scene.Action]: () => import(/* webpackChunkName: 'action' */ './actions/Action'), // TODO
[Scene.FeatureFlags]: () => import(/* webpackChunkName: 'featureFlags' */ './experimentation/FeatureFlags'),
[Scene.FeatureFlag]: () => import(/* webpackChunkName: 'featureFlag' */ './experimentation/FeatureFlag'),
[Scene.OrganizationSettings]: () =>
import(/* webpackChunkName: 'organizationSettings' */ './organization/Settings'),
[Scene.OrganizationCreateFirst]: () =>
import(/* webpackChunkName: 'organizationCreateFirst' */ './organization/Create'),
[Scene.ProjectSettings]: () => import(/* webpackChunkName: 'projectSettings' */ './project/Settings'),
[Scene.ProjectCreateFirst]: () => import(/* webpackChunkName: 'projectCreateFirst' */ './project/Create'),
[Scene.SystemStatus]: () => import(/* webpackChunkName: 'systemStatus' */ './instance/SystemStatus'),
[Scene.InstanceLicenses]: () => import(/* webpackChunkName: 'instanceLicenses' */ './instance/Licenses'),
[Scene.MySettings]: () => import(/* webpackChunkName: 'mySettings' */ './me/Settings'),
[Scene.Annotations]: () => import(/* webpackChunkName: 'annotations' */ './annotations'),
[Scene.PreflightCheck]: () => import(/* webpackChunkName: 'preflightCheck' */ './PreflightCheck'),
[Scene.Signup]: () => import(/* webpackChunkName: 'signup' */ './authentication/Signup'),
[Scene.InviteSignup]: () => import(/* webpackChunkName: 'inviteSignup' */ './authentication/InviteSignup'),
[Scene.Ingestion]: () => import(/* webpackChunkName: 'ingestion' */ './ingestion/IngestionWizard'),
[Scene.Billing]: () => import(/* webpackChunkName: 'billing' */ './billing/Billing'),
[Scene.Plugins]: () => import(/* webpackChunkName: 'plugins' */ './plugins/Plugins'),
[Scene.Personalization]: () => import(/* webpackChunkName: 'personalization' */ './onboarding/Personalization'),
[Scene.OnboardingSetup]: () => import(/* webpackChunkName: 'onboardingSetup' */ './onboarding/OnboardingSetup'),
[Scene.Login]: () => import(/* webpackChunkName: 'login' */ './authentication/Login'),
[Scene.SavedInsights]: () => import(/* webpackChunkName: 'savedInsights' */ './saved-insights/SavedInsights'),
[Scene.PasswordReset]: () => import(/* webpackChunkName: 'passwordReset' */ './authentication/PasswordReset'),
[Scene.PasswordResetComplete]: () =>
import(/* webpackChunkName: 'passwordResetComplete' */ './authentication/PasswordResetComplete'),
}
export const sceneConfigurations: Partial<Record<Scene, SceneConfig>> = {
// Project-based routes
[Scene.Dashboards]: {
projectBased: true,
},
[Scene.Dashboard]: {
projectBased: true,
},
[Scene.Insights]: {
projectBased: true,
dark: true,
},
[Scene.Cohorts]: {
projectBased: true,
},
[Scene.Events]: {
projectBased: true,
},
[Scene.Sessions]: {
projectBased: true,
},
[Scene.SessionRecordings]: {
projectBased: true,
},
[Scene.Person]: {
projectBased: true,
},
[Scene.Persons]: {
projectBased: true,
},
[Scene.Action]: {
projectBased: true,
},
[Scene.FeatureFlags]: {
projectBased: true,
},
[Scene.FeatureFlag]: {
projectBased: true,
},
[Scene.Annotations]: {
projectBased: true,
},
[Scene.Plugins]: {
projectBased: true,
},
[Scene.SavedInsights]: {
projectBased: true,
},
[Scene.ProjectSettings]: {
projectBased: true,
hideDemoWarnings: true,
},
[Scene.InsightRouter]: {
projectBased: true,
dark: true,
},
[Scene.Personalization]: {
projectBased: true,
plain: true,
hideTopNav: true,
},
[Scene.Ingestion]: {
projectBased: true,
plain: true,
},
[Scene.OnboardingSetup]: {
projectBased: true,
hideDemoWarnings: true,
},
// Organization-based routes
[Scene.OrganizationCreateFirst]: {
plain: true,
},
[Scene.ProjectCreateFirst]: {
plain: true,
},
[Scene.Billing]: {
hideDemoWarnings: true,
},
// Onboarding/setup routes
[Scene.Login]: {
onlyUnauthenticated: true,
},
[Scene.Signup]: {
onlyUnauthenticated: true,
},
[Scene.PreflightCheck]: {
onlyUnauthenticated: true,
},
[Scene.PasswordReset]: {
allowUnauthenticated: true,
},
[Scene.PasswordResetComplete]: {
allowUnauthenticated: true,
},
[Scene.InviteSignup]: {
allowUnauthenticated: true,
plain: true,
},
}
export const redirects: Record<string, string | ((params: Params) => string)> = {
'/': urls.insights(),
'/dashboards': urls.dashboards(),
'/plugins': urls.plugins(),
'/actions': '/events/actions',
'/organization/members': urls.organizationSettings(),
}
export const routes: Record<string, Scene> = {
[urls.dashboards()]: Scene.Dashboards,
[urls.dashboard(':id')]: Scene.Dashboard,
[urls.createAction()]: Scene.Action,
[urls.action(':id')]: Scene.Action,
[urls.insights()]: Scene.Insights,
[urls.insightRouter(':id')]: Scene.InsightRouter,
[urls.actions()]: Scene.Actions,
[urls.eventStats()]: Scene.EventStats,
[urls.eventPropertyStats()]: Scene.EventPropertyStats,
[urls.events()]: Scene.Events,
[urls.sessions()]: Scene.Sessions,
[urls.sessionRecordings()]: Scene.SessionRecordings,
[urls.person('*')]: Scene.Person,
[urls.persons()]: Scene.Persons,
[urls.cohort(':id')]: Scene.Cohorts,
[urls.cohorts()]: Scene.Cohorts,
[urls.featureFlags()]: Scene.FeatureFlags,
[urls.featureFlag(':id')]: Scene.FeatureFlag,
[urls.annotations()]: Scene.Annotations,
[urls.projectSettings()]: Scene.ProjectSettings,
[urls.plugins()]: Scene.Plugins,
[urls.projectCreateFirst()]: Scene.ProjectCreateFirst,
[urls.organizationSettings()]: Scene.OrganizationSettings,
[urls.organizationBilling()]: Scene.Billing,
[urls.organizationCreateFirst()]: Scene.OrganizationCreateFirst,
[urls.instanceLicenses()]: Scene.InstanceLicenses,
[urls.systemStatus()]: Scene.SystemStatus,
[urls.systemStatusPage(':id')]: Scene.SystemStatus,
[urls.mySettings()]: Scene.MySettings,
[urls.savedInsights()]: Scene.SavedInsights,
// Onboarding / setup routes
[urls.login()]: Scene.Login,
[urls.preflight()]: Scene.PreflightCheck,
[urls.signup()]: Scene.Signup,
[urls.inviteSignup(':id')]: Scene.InviteSignup,
[urls.passwordReset()]: Scene.PasswordReset,
[urls.passwordResetComplete(':uuid', ':token')]: Scene.PasswordResetComplete,
[urls.personalization()]: Scene.Personalization,
[urls.ingestion()]: Scene.Ingestion,
[urls.ingestion() + '/*']: Scene.Ingestion,
[urls.onboardingSetup()]: Scene.OnboardingSetup,
}

View File

@ -7,6 +7,8 @@ import { useValues } from 'kea'
import { router } from 'kea-router'
import { urls } from 'scenes/urls'
import { ArrowRightOutlined } from '@ant-design/icons'
import { SceneExport } from 'scenes/sceneTypes'
import { sessionRecordingsTableLogic } from 'scenes/session-recordings/sessionRecordingsTableLogic'
export function SessionsRecordings(): JSX.Element {
const { currentTeam } = useValues(teamLogic)
@ -42,3 +44,8 @@ export function SessionsRecordings(): JSX.Element {
</div>
)
}
export const scene: SceneExport = {
component: SessionsRecordings,
logic: sessionRecordingsTableLogic,
}

View File

@ -250,7 +250,7 @@ export const sessionsPlayLogic = kea<sessionsPlayLogicType>({
color: 'orange',
}))
return pageChangeEvents.concat(highlightedEvents).sort((a, b) => a.playerTime - b.playerTime)
return [...pageChangeEvents, ...highlightedEvents].sort((a, b) => a.playerTime - b.playerTime)
},
],
},

View File

@ -24,7 +24,7 @@ export function ActionsPie({
const { results } = useValues(logic)
function updateData(): void {
const _data = results as TrendResultWithAggregate[]
const _data = [...results] as TrendResultWithAggregate[]
_data.sort((a, b) => b.aggregated_value - a.aggregated_value)
const days = results.length > 0 ? results[0].days : []

View File

@ -14,7 +14,7 @@ export function ActionsTable(): JSX.Element {
let data = indexedResults as any as TrendResultWithAggregate[]
if (!filters.session && data) {
data = data.sort((a, b) => b.aggregated_value - a.aggregated_value)
data = [...data].sort((a, b) => b.aggregated_value - a.aggregated_value)
}
data = data.map((d: any) => {

View File

@ -8,12 +8,14 @@ export const urls = {
dashboard: (id: string | number) => `/dashboard/${id}`,
createAction: () => `/action`, // TODO: For consistency, this should be `/action/new`
action: (id: string | number) => `/action/${id}`,
actions: () => '/actions',
actions: () => '/events/actions',
eventStats: () => '/events/stats',
eventPropertyStats: () => '/events/properties',
events: () => '/events',
insights: () => '/insights',
insightView: (view: ViewType) => `/insights?insight=${view}`,
insightRouter: (id: string) => `/i/${id}`,
savedInsights: () => '/saved_insights',
events: () => '/events',
sessions: () => '/sessions',
sessionRecordings: () => '/recordings',
person: (id: string) => `/person/${id}`,

View File

@ -4,8 +4,32 @@ import { testUtilsPlugin, expectLogic } from 'kea-test-utils'
import { createMemoryHistory } from 'history'
import posthog from 'posthog-js'
import { AppContext } from '../types'
import { MOCK_TEAM_ID } from '../lib/api.mock'
import { MOCK_DEFAULT_TEAM } from '../lib/api.mock'
export function initKeaTests(): void {
window.POSTHOG_APP_CONTEXT = {
current_team: MOCK_DEFAULT_TEAM,
...window.POSTHOG_APP_CONTEXT,
} as unknown as AppContext
posthog.init('no token', {
api_host: 'borked',
test: true,
autocapture: false,
disable_session_recording: true,
advanced_disable_decide: true,
opt_out_capturing_by_default: true,
loaded: (p) => {
p.opt_out_capturing()
},
})
const history = createMemoryHistory()
;(history as any).pushState = history.push
;(history as any).replaceState = history.replace
initKea({ beforePlugins: [testUtilsPlugin], routerLocation: history.location, routerHistory: history })
}
/* do not call this within a 'test' or a 'beforeEach' block, only in 'describe' */
export function initKeaTestLogic<L extends Logic = Logic>({
logic,
props,
@ -19,36 +43,18 @@ export function initKeaTestLogic<L extends Logic = Logic>({
let unmount: () => void
beforeEach(async () => {
window.POSTHOG_APP_CONTEXT = {
current_team: { id: MOCK_TEAM_ID },
...window.POSTHOG_APP_CONTEXT,
} as unknown as AppContext
posthog.init('no token', {
api_host: 'borked',
test: true,
autocapture: false,
disable_session_recording: true,
advanced_disable_decide: true,
opt_out_capturing_by_default: true,
loaded: (p) => {
p.opt_out_capturing()
},
})
const history = createMemoryHistory()
;(history as any).pushState = history.push
;(history as any).replaceState = history.replace
initKea({ beforePlugins: [testUtilsPlugin], routerLocation: history.location, routerHistory: history })
initKeaTests()
if (logic) {
builtLogic = logic.build({ ...props })
await onLogic?.(builtLogic)
unmount = builtLogic.mount()
}
return unmount
})
afterEach(async () => {
if (logic) {
unmount()
unmount?.()
await expectLogic(logic).toFinishAllListeners()
}
delete window.POSTHOG_APP_CONTEXT

View File

@ -1039,6 +1039,12 @@ export interface InsightLogicProps {
doNotLoad?: boolean
}
export interface SetInsightOptions {
shouldMergeWithExisting?: boolean
/** this overrides the in-flight filters on the page, which may not equal the last returned API response */
overrideFilter?: boolean
}
export interface FeatureFlagGroupType {
properties: AnyPropertyFilter[]
rollout_percentage: number | null

View File

@ -78,10 +78,10 @@
"expr-eval": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^6.4.1",
"kea": "^2.5.1",
"kea": "^2.5.4",
"kea-loaders": "^0.5.1",
"kea-localstorage": "^1.1.1",
"kea-router": "^1.0.2",
"kea-router": "^1.0.5",
"kea-waitfor": "^0.2.1",
"kea-window-values": "^0.1.0",
"md5": "^2.3.0",

View File

@ -10719,10 +10719,10 @@ kea-localstorage@^1.1.1:
resolved "https://registry.yarnpkg.com/kea-localstorage/-/kea-localstorage-1.1.1.tgz#6edf69476779e002708fb10f2360e48333707406"
integrity sha512-YijSF33Y1QpfHAq1hGvulWWoGC9Kckd4lVa+XStHar4t7GfoaDa1aWnTeJxzz9bWnJovfk+MnG8ekP4rWIqINA==
kea-router@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/kea-router/-/kea-router-1.0.2.tgz#7ba20e6e5c97ae353918e38c0ca7a3eb9ed44e00"
integrity sha512-PsQhfl0j3kRcggBwC02+YsKzFa8zk46BSj9DbZvy/JzVXlBO7L3BTryo6kQSpZbU6v0HJgr7Oupaj396zsr9bA==
kea-router@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/kea-router/-/kea-router-1.0.5.tgz#1e6e2e820bf012904589b356356dcd3156360fb9"
integrity sha512-EIY/0SmdtLrKTxmoE+MaB1GOQ0HHr4a38TS4ub4ylQ7mQ+lyC2D8m4KIbYRYJ9wFvkEK2QOMsuVQbmfD1AXEOA==
dependencies:
url-pattern "^1.0.3"
@ -10753,10 +10753,10 @@ kea-window-values@^0.1.0:
resolved "https://registry.yarnpkg.com/kea-window-values/-/kea-window-values-0.1.0.tgz#d6e2045c14521082d9fd88f4e4a5e2c596ac4d0e"
integrity sha512-4OsoHaVU4+KFyfL4ZWKB5lVQbx6A7Azc2SVraGrzIglS/3DN/97WZ6RrDMHuitu02o9cm/CRmMsNB8FXSD5xdQ==
kea@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/kea/-/kea-2.5.1.tgz#7b396e11acd1aad65d2cdee0e19d3755c3d7830e"
integrity sha512-yvhnM/QHd70bCZwablIKDXuUoqrNljYoc5H4ibrUc0MW/ZrN/KTvesjXGAgkGb01m2H8jXFnzGOQXkxxngVtoQ==
kea@^2.5.4:
version "2.5.4"
resolved "https://registry.yarnpkg.com/kea/-/kea-2.5.4.tgz#0c4cdcab9a4c62b59377332fcc1637959f47cf3f"
integrity sha512-MFWPzkngDeKYQepGQtcujvXZYzlpMnHUYQAnMl3OJOzOkHHK81KFNY4T80x+l9ePb9Akn5Jmat7fbWcnS7/XBA==
killable@^1.0.1:
version "1.0.1"