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

Command palette (#1819)

* add hotkey base

* add platform check

* Rename CommandBox to CommandPalette

* Add styled-components

* Restructure CommandPalette and add Esc handling

* Update utils.js

* add search box

* Add logic types

* Implement base logic

* Fix scroll prevention

* Update logic and add goto command base with Fuse fuzzy serach

* ux hotkey

* Implement command (de)registration

* minor fix

* base UI

* error message & hints

* command groups UI

* Add commands search

* Make commands work

* Prepare palette for fuzzy search and add basic labeling

* ui adjustments

* ui

* Improve colors

* switch to global command

* Optimize resolution

* global go to commands

* abstract item selection logic to command palette

* Update casing and types

* Add conditional useCommands

* Improve results

* keyboard nav

* keyboard nav fix

* removed double fuse (synonyms broken)

* Update style

* Fix highlighting

* Remove extra style

* conditional go to commands

* add mouse hover handling and rename functions

* remove mac check

* add more commands

* added insights stub pages

* Add command components

* refactor logic

* Update styling

* Remove extraneous `input`

* load custom dashboards to command palette

* Add input styling

* Add write icon

* Refactor results

* minor adjustments

* add person search

* remove papercups fully

* fix input indexing

* fix result executing empty

* Add command grouping and improve navigation

* add urls

* make logic explicit

* fix error with hadnler

* Remove redundant border-top

* add trend functions

* personal api key stub

* Add command palette toggle button

* Fix things

* fix personal api keys

* copy api key to clipboard

* Add toggle text

* Improve palette UX and perform refactoring

* Improve UX and add palette usage reporting

* tests refactor

* filter person list

* refactor api person tests

* add squeak

* deprecate by_distinct_id & by_email person endpoints

* Optimize squeak

* fix typegen error

* use new filters in frontend

* Optimize squeak

* key identifier refactor

* fix mypy

* removed unused code

* Make custom command UI more coherent

* Add calculator to palette

* Use equal sign

* Make palette button nicer

* Add lodash back officially and show palette suggestions

lodash is still used in a few places, but it was not in package.json. The reason this was working was that lodash is a dependency of some other depedencies, but this was fragile. It's still not ideal to use this, but at least this is now not a hack the way it was.

* Remove isHint

* Optimize graph time range command

* Move command results grouping to Kea logic

* Fix result focus autoshift

* Improve palette result focusing

* Adjust for window.posthog being optional

* add test for third-party person filters

* Remove styled-components in favor of .scss files

* Remove redundant container class

* Use insect (sic!) squeak instead of pig squeak

* Show only unique palette results

* Fix palette overlay

* Add powerful command building protocol CommandFlow

* Fix minor issues

* Always show scope when flow active

* Use custom label icon

* Add feedback sharing command

* block command input from being captured in screen recording

at least until we can figure out how to capture this info in a privacy-preserving way

* Hide palette button on narrow screens

* Improve responsiveness

* Fix palette feedback sending

* Fix Esc handling

* Add Message Sent info

* Fix Message Sent info

* Fix dashboard creation and null name handling

* Rename Cy tests to JS convention

* Add basic Cypress test

* Address feedback

Co-authored-by: Eric <eeoneric@gmail.com>
Co-authored-by: Paolo D'Amico <paolodamico@users.noreply.github.com>
This commit is contained in:
Michael Matloka 2020-10-13 15:44:56 +02:00 committed by GitHub
parent 72fe8fdff9
commit f71e011a86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1793 additions and 259 deletions

View File

@ -0,0 +1,17 @@
describe('Command Palette', () => {
beforeEach(() => {
cy.visit('/events')
})
it('Shows on toggle button click', () => {
cy.get('[data-attr=command-palette-toggle]').click()
cy.get('[data-attr=command-palette-input]').should('exist')
})
it('Shows on Ctrl + K press', () => {
cy.get('body').type('{ctrl}C')
cy.get('[data-attr=command-palette-input]').should('exist')
cy.get('body').type('{cmd}C')
cy.get('[data-attr=command-palette-input]').should('not.exist')
})
})

View File

@ -0,0 +1,20 @@
describe('Feature Flags', () => {
beforeEach(() => {
cy.visit('/experiments/feature_flags')
})
it('Create feature flag', () => {
cy.get('h1').should('contain', 'Feature Flags')
cy.get('[data-attr=new-feature-flag]').click()
cy.get('[data-attr=feature-flag-name').type('beta feature').should('have.value', 'beta feature')
cy.get('[data-attr=feature-flag-key').should('have.value', 'beta-feature')
cy.get('[data-attr=feature-flag-switch').click()
cy.get('[data-attr=feature-flag-submit').click()
cy.get('[data-attr=feature-flag-table').should('contain', 'beta feature')
cy.get('[data-attr=feature-flag-table] tr:first-child td:first-child').click()
cy.get('[data-attr=feature-flag-name').type(' updated').should('have.value', 'beta feature updated')
cy.get('[data-attr=feature-flag-submit').click()
cy.get('[data-attr=feature-flag-table').should('contain', 'beta feature updated')
})
})

View File

@ -0,0 +1,20 @@
describe('Live Actions', () => {
beforeEach(() => {
cy.get('[data-attr=menu-item-events]').click()
cy.get('[data-attr=menu-item-live-actions]').click()
})
/* it('Live actions loaded', () => {
cy.get('[data-attr=events-table]').should('exist')
})
*/
it('Apply 1 overall filter', () => {
cy.get('[data-attr=new-prop-filter-LiveActionsTable]').click()
cy.get('[data-attr=property-filter-dropdown]').click()
cy.get('[data-attr=prop-filter-event-1]').click()
cy.get('[data-attr=prop-val]').click()
cy.get('[data-attr=prop-val-1]').click()
cy.get('[data-attr=events-table]').should('exist')
})
})

View File

@ -0,0 +1,123 @@
describe('Trends actions & events', () => {
beforeEach(() => {
// given
cy.visit('/insights')
})
it('Insight History Panel Rendered', () => {
cy.get('[data-attr=insight-history-button]').click()
cy.get('[data-attr=insight-history-panel]').should('exist')
})
it('Add a pageview action filter', () => {
// when
cy.contains('Add action/event').click()
cy.get('[data-attr=trend-element-subject-1]').click()
cy.contains('Pageviews').click()
// then
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('DAU on 1 element', () => {
cy.get('[data-attr=math-selector-0]').click()
cy.get('[data-attr=math-dau-0]').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Show property select dynamically', () => {
cy.get('[data-attr=math-property-selector-0]').should('not.exist')
cy.get('[data-attr=math-selector-0]').click()
cy.get('[data-attr=math-avg-0]').click()
cy.get('[data-attr=math-property-selector-0]').should('exist')
})
it('Apply specific filter on default pageview event', () => {
cy.get('[data-attr=show-prop-filter-0]').click()
cy.get('[data-attr=new-prop-filter-0-\\$pageview-filter]').click()
cy.get('[data-attr=property-filter-dropdown]').click()
cy.get('[data-attr=prop-filter-event-1]').click()
cy.get('#rc_select_6').click()
cy.get('[data-attr=prop-val-0]').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Apply 1 overall filter', () => {
cy.get('[data-attr=new-prop-filter-trends-filters]').click()
cy.get('[data-attr=property-filter-dropdown]').click()
cy.get('[data-attr=prop-filter-event-1]').click()
cy.get('[data-attr=prop-val]').click()
cy.get('[data-attr=prop-val-0]').click()
cy.get('[data-attr=trend-line-graph]', { timeout: 8000 }).should('exist')
})
it('Apply interval filter', () => {
cy.get('[data-attr=interval-filter]').click()
cy.contains('Weekly').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Apply chart filter', () => {
cy.get('[data-attr=chart-filter]').click()
cy.contains('Pie').click()
cy.get('[data-attr=trend-pie-graph]').should('exist')
})
it('Apply table filter', () => {
cy.get('[data-attr=chart-filter]').click()
cy.contains('Table').click()
cy.get('[data-attr=trend-table-graph]').should('exist')
})
it('Apply date filter', () => {
cy.get('[data-attr=date-filter]').click()
cy.contains('Last 30 days').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Apply volume filter', () => {
cy.get('[data-attr=shownas-filter]').click()
cy.get('[data-attr=shownas-volume-option]').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Apply stickiness filter', () => {
cy.get('[data-attr=shownas-filter]').click()
cy.get('[data-attr=shownas-stickiness-option]').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Apply property breakdown', () => {
cy.get('[data-attr=add-breakdown-button]').click()
cy.get('[data-attr=prop-breakdown-select]').click()
cy.get('[data-attr=prop-breakdown-3]').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Apply all users cohort breakdown', () => {
cy.get('[data-attr=add-breakdown-button]').click()
cy.contains('Cohort').click()
cy.get('[data-attr=cohort-breakdown-select]').click()
cy.get('[data-attr=cohort-breakdown-all-users]').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Save to dashboard', () => {
cy.get('[data-attr=save-to-dashboard-button]').click()
cy.get('.ant-input').type('Pageviews')
cy.get('form > .ant-select > .ant-select-selector').click()
cy.get(':nth-child(1) > .ant-select-item-option-content').click()
cy.contains('Add panel to dashboard').click()
cy.wait(300) // not ideal but toast has a delay render
cy.get('[data-attr=success-toast]').should('exist')
})
})

View File

@ -0,0 +1,42 @@
describe('Trends sessions', () => {
beforeEach(() => {
// given
cy.visit('/insights')
cy.get('[id="rc-tabs-0-tab-SESSIONS"]').click()
})
it('Sessions exists', () => {
// then
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Apply 1 overall filter', () => {
cy.get('[data-attr=new-prop-filter-trends-sessions]').click()
cy.get('[data-attr=property-filter-dropdown]').click()
cy.get('[data-attr=prop-filter-event-1]').click()
cy.get('[data-attr=prop-val]').click()
cy.get('[data-attr=prop-val-1]').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
/* it('Apply table filter', () => {
cy.get('[data-attr=chart-filter]').click()
cy.contains('Table').click()
cy.get('[data-attr=trend-table-graph]').should('exist')
}) */
it('Apply date filter', () => {
cy.get('[data-attr=date-filter]').click()
cy.contains('Last 30 days').click()
cy.get('[data-attr=trend-line-graph]').should('exist')
})
it('Save to dashboard', () => {
cy.get('[data-attr=save-to-dashboard-button]').click()
cy.contains('Add panel to dashboard').click()
cy.get('[data-attr=success-toast]').should('exist')
})
})

View File

@ -0,0 +1,10 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 61.1428L19.8771 75.9999H5V61.1428ZM5 57.4284L23.5964 75.9999H38.4734L5 42.5714V57.4284ZM5 38.8571L42.1927 75.9999H57.0699L5 24V38.8571ZM23.5964 38.8571L60.7892 75.9999V61.1428L23.5964 24V38.8571ZM42.1927 24V38.8571L60.7892 57.4284V42.5714L42.1927 24Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M84.1043 65.8557C86.7971 68.5448 90.451 70.0573 94.2625 70.0573V76H63.4192V45.1982L84.1043 65.8557ZM75.321 67.0857C75.321 68.7268 73.9889 70.057 72.3457 70.057C70.7024 70.057 69.3701 68.7268 69.3701 67.0857C69.3701 65.4447 70.7024 64.1143 72.3457 64.1143C73.9889 64.1143 75.321 65.4447 75.321 67.0857Z" fill="white"/>
<path d="M5 75.9997H19.8771L5 61.1426V75.9997Z" fill="white"/>
<path d="M23.5964 42.5714L5 24V38.8571L23.5964 57.4284V42.5714Z" fill="white"/>
<path d="M5 42.5714V57.4286L23.5964 75.9999V61.1428L5 42.5714Z" fill="white"/>
<path d="M42.1927 42.5714L23.5964 24V38.8571L42.1927 57.4284V42.5714Z" fill="white"/>
<path d="M23.5964 75.9997H38.4734L23.5964 61.1426V75.9997Z" fill="white"/>
<path d="M23.5964 42.5714V57.4286L42.1927 75.9999V61.1428L23.5964 42.5714Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
frontend/public/squeak.mp3 Normal file

Binary file not shown.

View File

@ -137,7 +137,7 @@ export function Sidebar({ user, sidebarCollapsed, setSidebarCollapsed }) {
title=""
>
<LineChartOutlined />
<span className="sidebar-label">{dashboard.name}</span>
<span className="sidebar-label">{dashboard.name ?? 'Untitled'}</span>
<Link to={`/dashboard/${dashboard.id}`} onClick={collapseSidebar} />
</Menu.Item>
))}
@ -245,7 +245,7 @@ export function Sidebar({ user, sidebarCollapsed, setSidebarCollapsed }) {
<Link to={'/annotations'} onClick={collapseSidebar} />
</Menu.Item>
{featureFlags && featureFlags['billing-management-page'] && (
{featureFlags['billing-management-page'] && (
<Menu.Item key="billing" style={itemStyle} data-attr="menu-item-billing">
<WalletOutlined />
<span className="sidebar-label">Billing</span>

View File

@ -1,27 +0,0 @@
import './TopContent.scss'
import React from 'react'
import { LatestVersion } from '~/layout/LatestVersion'
import { User } from '~/layout/User'
import { WorkerStats } from '~/layout/WorkerStats'
export function TopContent() {
return (
<div>
<div
className="layout-top-content right-align"
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
fontSize: 13,
}}
>
<LatestVersion />
<WorkerStats />
<User />
</div>
</div>
)
}

View File

@ -0,0 +1,22 @@
import React from 'react'
import { useActions, useValues } from 'kea'
import { commandPaletteLogic } from 'lib/components/CommandPalette/commandPaletteLogic'
import { SearchOutlined } from '@ant-design/icons'
import { platformCommandControlKey } from 'lib/utils'
export function CommandPaletteButton(): JSX.Element {
const { isPaletteShown } = useValues(commandPaletteLogic)
const { showPalette } = useActions(commandPaletteLogic)
return (
<span
data-attr="command-palette-toggle"
className="btn btn-sm btn-light hide-when-small"
onClick={showPalette}
title={isPaletteShown ? 'Hide Command Palette' : 'Show Command Palette'}
>
<SearchOutlined size={1} style={{ marginRight: '0.5rem' }} />
{platformCommandControlKey()} + K
</span>
)
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import api from './../lib/api'
import api from '../../lib/api'
import { Modal, Button } from 'antd'
import { WarningOutlined } from '@ant-design/icons'

View File

@ -1,4 +1,6 @@
.layout-top-content {
display: flex;
justify-content: space-between;
.hide-when-small {
display: inline;
}

View File

@ -0,0 +1,40 @@
import React from 'react'
import { LatestVersion } from './LatestVersion'
import { User } from './User'
import { WorkerStats } from './WorkerStats'
import { CommandPaletteButton } from './CommandPaletteButton'
import { isMobile } from 'lib/utils'
import './index.scss'
export function TopContent(): JSX.Element {
return (
<div className="content py-3 layout-top-content">
<div
className="layout-top-content"
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
fontSize: 13,
}}
>
{!isMobile() && <CommandPaletteButton />}
</div>
<div
className="layout-top-content"
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
fontSize: 13,
}}
>
<LatestVersion />
<WorkerStats />
<User />
</div>
</div>
)
}

View File

@ -0,0 +1,32 @@
import React from 'react'
import { SearchOutlined, EditOutlined } from '@ant-design/icons'
import { useValues, useActions } from 'kea'
import { commandPaletteLogic } from './commandPaletteLogic'
import PostHogIcon from './../../../../public/icon-white.svg'
import rrwebBlockClass from 'lib/utils/rrwebBlockClass'
export function CommandInput(): JSX.Element {
const { input, isSqueak, activeFlow } = useValues(commandPaletteLogic)
const { setInput } = useActions(commandPaletteLogic)
return (
<div className="palette__row">
{isSqueak ? (
<img src={PostHogIcon} className="palette__icon" />
) : activeFlow ? (
<activeFlow.icon className="palette__icon" /> ?? <EditOutlined className="palette__icon" />
) : (
<SearchOutlined className="palette__icon" />
)}
<input
className={`palette__display palette__input ${rrwebBlockClass}`}
autoFocus
value={input}
onChange={(event) => {
setInput(event.target.value)
}}
placeholder={activeFlow?.instruction ?? 'What would you like to do? Try some suggestions…'}
/>
</div>
)
}

View File

@ -0,0 +1,84 @@
import React from 'react'
import { CommandResult as CommandResultType } from './commandPaletteLogic'
import { useEventListener } from 'lib/hooks/useEventListener'
import { useActions, useMountedLogic, useValues } from 'kea'
import { commandPaletteLogic } from './commandPaletteLogic'
interface CommandResultProps {
result: CommandResultType
focused?: boolean
}
function CommandResult({ result, focused }: CommandResultProps): JSX.Element {
const { onMouseEnterResult, onMouseLeaveResult, executeResult } = useActions(commandPaletteLogic)
const isExecutable = !!result.executor
return (
<div
className={`palette_row palette__result ${focused ? 'palette__result--focused' : ''} ${
isExecutable ? 'palette__result--executable' : ''
}`}
onMouseEnter={() => {
onMouseEnterResult(result.index)
}}
onMouseLeave={() => {
onMouseLeaveResult()
}}
onClick={() => {
if (isExecutable) executeResult(result)
}}
>
<result.icon className="palette__icon" />
<div className="palette__display">{result.display}</div>
</div>
)
}
interface ResultsGroupProps {
scope: string
results: CommandResultType[]
activeResultIndex: number
}
export function ResultsGroup({ scope, results, activeResultIndex }: ResultsGroupProps): JSX.Element {
return (
<>
<div className="palette__row palette__row--small palette__scope">{scope}</div>
{results.map((result) => (
<CommandResult
result={result}
focused={result.index === activeResultIndex}
key={`command-result-${result.index}`}
/>
))}
</>
)
}
export function CommandResults(): JSX.Element {
useMountedLogic(commandPaletteLogic)
const { activeResultIndex, commandSearchResults, commandSearchResultsGrouped } = useValues(commandPaletteLogic)
const { executeResult, onArrowUp, onArrowDown } = useActions(commandPaletteLogic)
useEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter' && commandSearchResults.length) {
const result = commandSearchResults[activeResultIndex]
const isExecutable = !!result.executor
if (isExecutable) executeResult(result)
} else if (event.key === 'ArrowDown') {
onArrowDown(commandSearchResults.length - 1)
} else if (event.key === 'ArrowUp') {
onArrowUp()
}
})
return (
<div className="palette__results">
{commandSearchResultsGrouped.map(([scope, results]) => (
<ResultsGroup key={scope} scope={scope} results={results} activeResultIndex={activeResultIndex} />
))}
</div>
)
}

View File

@ -0,0 +1,680 @@
import { kea } from 'kea'
import { router } from 'kea-router'
import { commandPaletteLogicType } from 'types/lib/components/CommandPalette/commandPaletteLogicType'
import Fuse from 'fuse.js'
import { dashboardsModel } from '~/models/dashboardsModel'
import { Parser } from 'expr-eval'
import _ from 'lodash'
import {
CommentOutlined,
FundOutlined,
RiseOutlined,
ContainerOutlined,
AimOutlined,
CheckOutlined,
SyncOutlined,
TagOutlined,
ClockCircleOutlined,
UserOutlined,
UsergroupAddOutlined,
ExperimentOutlined,
SettingOutlined,
MessageOutlined,
TeamOutlined,
LinkOutlined,
CalculatorOutlined,
FunnelPlotOutlined,
GatewayOutlined,
InteractionOutlined,
MailOutlined,
KeyOutlined,
VideoCameraOutlined,
SendOutlined,
LogoutOutlined,
PlusOutlined,
LineChartOutlined,
} from '@ant-design/icons'
import { DashboardType } from '~/types'
import api from 'lib/api'
import { appUrlsLogic } from '../AppEditorLink/appUrlsLogic'
import { copyToClipboard, isURL } from 'lib/utils'
import { personalAPIKeysLogic } from '../PersonalAPIKeys/personalAPIKeysLogic'
// If CommandExecutor returns CommandFlow, flow will be entered
export type CommandExecutor = () => CommandFlow | void
export interface CommandResultTemplate {
icon: any // any, because Ant Design icons are some weird ForwardRefExoticComponent type
display: string
synonyms?: string[]
prefixApplied?: string
executor?: CommandExecutor | true // true means "just clear input"
guarantee?: boolean // show result always and first, regardless of fuzzy search
}
export type CommandResult = CommandResultTemplate & {
source: Command | CommandFlow
index?: number
}
export type CommandResolver = (
argument?: string,
prefixApplied?: string
) => CommandResultTemplate[] | CommandResultTemplate | null
export interface Command {
key: string // Unique command identification key
prefixes?: string[] // Command prefixes, e.g. "go to". Prefix-less case is dynamic base command (e.g. Dashboard)
resolver: CommandResolver | CommandResultTemplate[] | CommandResultTemplate // Resolver based on arguments (prefix excluded)
scope: string
}
export interface CommandFlow {
icon?: any
instruction?: string
resolver: CommandResolver | CommandResultTemplate[] | CommandResultTemplate
scope: string
}
export type CommandRegistrations = {
[commandKey: string]: Command
}
export type RegExpCommandPairs = [RegExp | null, Command][]
const RESULTS_MAX = 5
const GLOBAL_COMMAND_SCOPE = 'global'
function resolveCommand(source: Command | CommandFlow, argument?: string, prefixApplied?: string): CommandResult[] {
// run resolver or use ready-made results
let results = source.resolver instanceof Function ? source.resolver(argument, prefixApplied) : source.resolver
if (!results) return [] // skip if no result
if (!Array.isArray(results)) results = [results] // work with a single result and with an array of results
const resultsWithCommand: CommandResult[] = results.map((result) => {
return { ...result, source }
})
return resultsWithCommand
}
export const commandPaletteLogic = kea<
commandPaletteLogicType<Command, CommandRegistrations, CommandResult, CommandFlow, RegExpCommandPairs>
>({
connect: {
actions: [personalAPIKeysLogic, ['createKey']],
values: [appUrlsLogic, ['appUrls', 'suggestions']],
},
actions: {
hidePalette: true,
showPalette: true,
togglePalette: true,
setInput: (input: string) => ({ input }),
onArrowUp: true,
onArrowDown: (maxIndex: number) => ({ maxIndex }),
onMouseEnterResult: (index: number) => ({ index }),
onMouseLeaveResult: true,
executeResult: (result: CommandResult) => ({ result }),
activateFlow: (flow: CommandFlow | null) => ({ flow }),
registerCommand: (command: Command) => ({ command }),
deregisterCommand: (commandKey: string) => ({ commandKey }),
setCustomCommand: (commandKey: string) => ({ commandKey }),
deregisterScope: (scope: string) => ({ scope }),
},
reducers: {
isPaletteShown: [
false,
{
hidePalette: () => false,
showPalette: () => true,
togglePalette: (previousState) => !previousState,
},
],
keyboardResultIndex: [
0,
{
setInput: () => 0,
executeResult: () => 0,
activateFlow: () => 0,
onArrowUp: (previousIndex) => (previousIndex > 0 ? previousIndex - 1 : 0),
onArrowDown: (previousIndex, { maxIndex }) => (previousIndex < maxIndex ? previousIndex + 1 : maxIndex),
},
],
hoverResultIndex: [
null as number | null,
{
onMouseEnterResult: (_, { index }) => index,
onMouseLeaveResult: () => null,
onArrowUp: () => null,
onArrowDown: () => null,
activateFlow: () => null,
},
],
input: [
'',
{
setInput: (_, { input }) => input,
activateFlow: () => '',
executeResult: () => '',
},
],
activeFlow: [
null as CommandFlow | null,
{
activateFlow: (_, { flow }) => flow,
},
],
rawCommandRegistrations: [
{} as CommandRegistrations,
{
registerCommand: (commands, { command }) => {
return { ...commands, [command.key]: command }
},
deregisterCommand: (commands, { commandKey }) => {
const { [commandKey]: _, ...cleanedCommands } = commands // eslint-disable-line
return cleanedCommands
},
},
],
},
listeners: ({ actions, values }) => ({
showPalette: () => {
window.posthog?.capture('palette shown')
},
togglePalette: () => {
if (values.isPaletteShown) window.posthog?.capture('palette shown')
},
executeResult: ({ result }: { result: CommandResult }) => {
if (result.executor === true) {
actions.activateFlow(null)
} else {
const possibleFlow = result.executor?.() ?? null
actions.activateFlow(possibleFlow)
if (!possibleFlow) actions.hidePalette()
}
// Capture command execution, without useless data
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { icon, index, ...cleanedResult }: Record<string, any> = result
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { resolver, ...cleanedCommand } = cleanedResult.source
cleanedResult.source = cleanedCommand
window.posthog?.capture('palette command executed', cleanedResult)
},
deregisterScope: ({ scope }) => {
for (const command of Object.values(values.commandRegistrations)) {
if (command.scope === scope) actions.deregisterCommand(command.key)
}
},
setInput: async ({ input }, breakpoint) => {
await breakpoint(300)
if (input.length > 8) {
const response = await api.get('api/person/?key_identifier=' + input)
const person = response.results[0]
if (person) {
actions.registerCommand({
key: `person-${person.distinct_ids[0]}`,
resolver: [
{
icon: UserOutlined,
display: `View person ${input}`,
executor: () => {
const { push } = router.actions
push(`/person/${person.distinct_ids[0]}`)
},
},
],
scope: GLOBAL_COMMAND_SCOPE,
})
}
}
},
}),
selectors: {
isSqueak: [
(selectors) => [selectors.input],
(input: string) => {
return input.trim().toLowerCase() === 'squeak'
},
],
activeResultIndex: [
(selectors) => [selectors.keyboardResultIndex, selectors.hoverResultIndex],
(keyboardResultIndex: number, hoverResultIndex: number | null) => {
return hoverResultIndex ?? keyboardResultIndex
},
],
commandRegistrations: [
(selectors) => [
selectors.rawCommandRegistrations,
dashboardsModel.selectors.dashboards,
appUrlsLogic({ actionId: null }).selectors.appUrls,
appUrlsLogic({ actionId: null }).selectors.suggestions,
],
(rawCommandRegistrations: CommandRegistrations, dashboards: DashboardType[]): CommandRegistrations => ({
...rawCommandRegistrations,
custom_dashboards: {
key: 'custom_dashboards',
resolver: dashboards.map((dashboard: DashboardType) => ({
key: `dashboard_${dashboard.id}`,
icon: LineChartOutlined,
display: `Go to Dashboard ${dashboard.name}`,
executor: () => {
const { push } = router.actions
push(`/dashboard/${dashboard.id}`)
},
})),
scope: GLOBAL_COMMAND_SCOPE,
},
}),
],
regexpCommandPairs: [
(selectors) => [selectors.commandRegistrations],
(commandRegistrations: CommandRegistrations) => {
const array: RegExpCommandPairs = []
for (const command of Object.values(commandRegistrations)) {
if (command.prefixes)
array.push([new RegExp(`^\\s*(${command.prefixes.join('|')})(?:\\s+(.*)|$)`, 'i'), command])
else array.push([null, command])
}
return array
},
],
commandSearchResults: [
(selectors) => [selectors.regexpCommandPairs, selectors.input, selectors.activeFlow, selectors.isSqueak],
(
regexpCommandPairs: RegExpCommandPairs,
argument: string,
activeFlow: CommandFlow | null,
isSqueak: boolean
) => {
if (isSqueak) return []
if (activeFlow) return resolveCommand(activeFlow, argument)
let directResults: CommandResult[] = []
let prefixedResults: CommandResult[] = []
for (const [regexp, command] of regexpCommandPairs) {
if (regexp) {
const match = argument.match(regexp)
if (match && match[1]) {
prefixedResults = [...prefixedResults, ...resolveCommand(command, match[2], match[1])]
}
}
directResults = [...directResults, ...resolveCommand(command, argument)]
}
const allResults = directResults.concat(prefixedResults)
let fusableResults: CommandResult[] = []
let guaranteedResults: CommandResult[] = []
for (const result of allResults) {
if (result.guarantee) guaranteedResults.push(result)
else fusableResults.push(result)
}
fusableResults = _.uniqBy(fusableResults, 'display')
guaranteedResults = _.uniqBy(guaranteedResults, 'display')
const fusedResults = argument
? new Fuse(fusableResults, {
keys: ['display', 'synonyms'],
})
.search(argument)
.slice(0, RESULTS_MAX)
.map((result) => result.item)
: _.sampleSize(fusableResults, RESULTS_MAX - guaranteedResults.length)
const finalResults = guaranteedResults.concat(fusedResults)
// put global scope last
return finalResults.sort((resultA, resultB) =>
resultA.source.scope === resultB.source.scope
? 0
: resultA.source.scope === GLOBAL_COMMAND_SCOPE
? 1
: -1
)
},
],
commandSearchResultsGrouped: [
(selectors) => [selectors.commandSearchResults, selectors.activeFlow],
(commandSearchResults: CommandResult[], activeFlow: CommandFlow | null) => {
const resultsGrouped: { [scope: string]: CommandResult[] } = {}
if (activeFlow) resultsGrouped[activeFlow.scope] = []
for (const result of commandSearchResults) {
const scope: string = result.source.scope
if (!(scope in resultsGrouped)) resultsGrouped[scope] = [] // Ensure there's an array to push to
resultsGrouped[scope].push({ ...result })
}
let rollingIndex = 0
const resultsGroupedInOrder = Object.entries(resultsGrouped)
for (const [, group] of resultsGroupedInOrder) {
for (const result of group) {
result.index = rollingIndex++
}
}
return resultsGroupedInOrder
},
],
},
events: ({ actions }) => ({
afterMount: () => {
const { push } = router.actions
const goTo: Command = {
key: 'go-to',
scope: GLOBAL_COMMAND_SCOPE,
prefixes: ['open', 'visit'],
resolver: [
{
icon: FundOutlined,
display: 'Go to Dashboards',
executor: () => {
push('/dashboard')
},
},
{
icon: RiseOutlined,
display: 'Go to Insights',
executor: () => {
push('/insights')
},
},
{
icon: RiseOutlined,
display: 'Go to Trends',
executor: () => {
// FIXME: Don't reset insight on change
push('/insights?insight=TRENDS')
},
},
{
icon: ClockCircleOutlined,
display: 'Go to Sessions',
executor: () => {
// FIXME: Don't reset insight on change
push('/insights?insight=SESSIONS')
},
},
{
icon: FunnelPlotOutlined,
display: 'Go to Funnels',
executor: () => {
// FIXME: Don't reset insight on change
push('/insights?insight=FUNNELS')
},
},
{
icon: GatewayOutlined,
display: 'Go to Retention',
executor: () => {
// FIXME: Don't reset insight on change
push('/insights?insight=RETENTION')
},
},
{
icon: InteractionOutlined,
display: 'Go to User Paths',
executor: () => {
// FIXME: Don't reset insight on change
push('/insights?insight=PATHS')
},
},
{
icon: ContainerOutlined,
display: 'Go to Events',
executor: () => {
push('/events')
},
},
{
icon: AimOutlined,
display: 'Go to Actions',
executor: () => {
push('/actions')
},
},
{
icon: SyncOutlined,
display: 'Go to Live Actions',
executor: () => {
push('/actions/live')
},
},
{
icon: ClockCircleOutlined,
display: 'Go to Live Sessions',
executor: () => {
push('/sessions')
},
},
{
icon: UserOutlined,
display: 'Go to People',
synonyms: ['people'],
executor: () => {
push('/people')
},
},
{
icon: UsergroupAddOutlined,
display: 'Go to Cohorts',
executor: () => {
push('/people/cohorts')
},
},
{
icon: ExperimentOutlined,
display: 'Go to Experiments',
synonyms: ['feature flags', 'a/b tests'],
executor: () => {
push('/experiments/feature_flags')
},
},
{
icon: SettingOutlined,
display: 'Go to Setup',
synonyms: ['settings', 'configuration'],
executor: () => {
push('/setup')
},
},
{
icon: MessageOutlined,
display: 'Go to Annotations',
executor: () => {
push('/annotations')
},
},
{
icon: TeamOutlined,
display: 'Go to Team',
executor: () => {
push('/team')
},
},
{
icon: PlusOutlined,
display: 'Create Action',
executor: () => {
push('/action')
},
},
{
icon: LogoutOutlined,
display: 'Log Out',
executor: () => {
window.location.href = '/logout'
},
},
],
}
const calculator: Command = {
key: 'calculator',
scope: GLOBAL_COMMAND_SCOPE,
resolver: (argument) => {
// don't try evaluating if there's no argument or if it's a plain number already
if (!argument || !isNaN(+argument)) return null
try {
const result = +Parser.evaluate(argument)
return isNaN(result)
? null
: {
icon: CalculatorOutlined,
display: `= ${result}`,
guarantee: true,
executor: () => {
copyToClipboard(result.toString(), 'calculation result')
},
}
} catch {
return null
}
},
}
const openUrls: Command = {
key: 'open-urls',
scope: GLOBAL_COMMAND_SCOPE,
prefixes: ['open', 'visit'],
resolver: (argument) => {
const results: CommandResultTemplate[] = (appUrlsLogic.values.appUrls ?? [])
.concat(appUrlsLogic.values.suggestedUrls ?? [])
.map((url: string) => ({
icon: LinkOutlined,
display: `Open ${url}`,
synonyms: [`Visit ${url}`],
executor: () => {
open(url)
},
}))
if (isURL(argument))
results.push({
icon: LinkOutlined,
display: `Open ${argument}`,
synonyms: [`Visit ${argument}`],
executor: () => {
open(argument)
},
})
results.push({
icon: LinkOutlined,
display: 'Open PostHog Docs',
synonyms: ['technical documentation'],
executor: () => {
open('https://posthog.com/docs')
},
})
return results
},
}
const createPersonalApiKey: Command = {
key: 'create-personal-api-key',
scope: GLOBAL_COMMAND_SCOPE,
resolver: {
icon: KeyOutlined,
display: 'Create Personal API Key',
executor: () => ({
instruction: 'Give your key a label',
icon: TagOutlined,
scope: 'Creating Personal API Key',
resolver: (argument) => {
if (argument?.length)
return {
icon: KeyOutlined,
display: `Create Key "${argument}"`,
executor: () => {
personalAPIKeysLogic.actions.createKey(argument)
push('/setup', {}, 'personal-api-keys')
},
}
return null
},
}),
},
}
const createDashboard: Command = {
key: 'create-dashboard',
scope: GLOBAL_COMMAND_SCOPE,
resolver: {
icon: FundOutlined,
display: 'Create Dashboard',
executor: () => ({
instruction: 'Name your new dashboard',
icon: TagOutlined,
scope: 'Creating Dashboard',
resolver: (argument) => {
if (argument?.length)
return {
icon: FundOutlined,
display: `Create Dashboard "${argument}"`,
executor: () => {
dashboardsModel.actions.addDashboard({ name: argument, push: true })
},
}
return null
},
}),
},
}
const shareFeedback: Command = {
key: 'share-feedback',
scope: GLOBAL_COMMAND_SCOPE,
resolver: {
icon: CommentOutlined,
display: 'Share Feedback',
synonyms: ['send opinion', 'ask question', 'message posthog'],
executor: () => ({
scope: 'Sharing Feedback',
instruction: "What's on your mind?",
icon: CommentOutlined,
resolver: (argument) => [
{
icon: SendOutlined,
display: 'Send Message Directly to PostHog',
executor: !argument?.length
? undefined
: () => {
window.posthog?.capture('palette feedback', { message: argument })
return {
scope: 'Sharing Feedback',
resolver: {
icon: CheckOutlined,
display: 'Message Sent!',
executor: true,
},
}
},
},
{
icon: VideoCameraOutlined,
display: 'Schedule Quick Call',
executor: () => {
open('https://calendly.com/posthog-feedback')
},
},
{
icon: MailOutlined,
display: 'Email Core Team',
executor: () => {
open('mailto:hey@posthog.com')
},
},
],
}),
},
}
actions.registerCommand(goTo)
actions.registerCommand(openUrls)
actions.registerCommand(calculator)
actions.registerCommand(createPersonalApiKey)
actions.registerCommand(createDashboard)
actions.registerCommand(shareFeedback)
},
beforeUnmount: () => {
actions.deregisterCommand('go-to')
actions.deregisterCommand('open-urls')
actions.deregisterCommand('calculator')
actions.deregisterCommand('create-personal-api-key')
actions.deregisterCommand('create-dashboard')
actions.deregisterCommand('share-feedback')
},
}),
})

View File

@ -0,0 +1,107 @@
.palette__overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.palette__box {
z-index: 99999;
position: absolute;
top: 30%;
display: flex;
flex-direction: column;
width: 36rem;
max-width: 100%;
max-height: 60%;
color: #fff;
}
.palette__row {
display: flex;
align-items: center;
height: 4rem;
width: 100%;
padding: 0 1.875rem;
font-size: 1rem;
line-height: 4rem;
}
.palette__row--small {
height: 1.5rem;
line-height: 1.5rem;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: bold;
}
.palette__display {
padding-left: 1.5rem;
font-size: 1rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.palette__input {
flex-grow: 1;
border: none;
outline: none;
background: transparent;
color: #fff;
overflow-y: scroll;
}
.palette__results {
overflow-y: scroll;
}
.palette__result {
height: 4rem;
width: 100%;
padding: 0 1.875rem;
display: flex;
align-items: center;
font-size: 1rem;
position: relative;
cursor: pointer;
}
.palette__result--focused {
background: rgba(0, 0, 0, 0.35);
&::before,
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0.375rem;
}
&::before {
background: hsla(210, 10%, 19%, 1) !important;
}
&::after {
background: rgba(255, 255, 255, 0.1);
}
}
.palette__result--executable::after {
background: #1890ff;
}
.palette__scope {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
.palette__icon {
display: flex;
align-items: center;
width: 1rem;
height: 100%;
}

View File

@ -0,0 +1,52 @@
import React, { useRef, useMemo } from 'react'
import { useOutsideClickHandler } from 'lib/hooks/useOutsideClickHandler'
import { useMountedLogic, useValues, useActions } from 'kea'
import { commandPaletteLogic } from './commandPaletteLogic'
import { CommandInput } from './CommandInput'
import { CommandResults } from './CommandResults'
import { userLogic } from 'scenes/userLogic'
import { useEventListener } from 'lib/hooks/useEventListener'
import squeakFile from './../../../../public/squeak.mp3'
import './index.scss'
export function CommandPalette(): JSX.Element | null {
useMountedLogic(commandPaletteLogic)
const { setInput, hidePalette, togglePalette, executeResult, activateFlow } = useActions(commandPaletteLogic)
const { input, isPaletteShown, isSqueak, activeFlow, commandSearchResults } = useValues(commandPaletteLogic)
const { user } = useValues(userLogic)
const squeakAudio: HTMLAudioElement | null = useMemo(
() => squeakAudio || (isSqueak ? new Audio(squeakFile) : null),
[isSqueak]
)
const boxRef = useRef<HTMLDivElement | null>(null)
useEventListener('keydown', (event: KeyboardEvent) => {
if (isSqueak && event.key === 'Enter') {
squeakAudio?.play()
} else if (event.key === 'Escape') {
event.preventDefault()
// First of all, exit flow
if (activeFlow) activateFlow(null)
// Else just erase input
else if (input) setInput('')
// Lastly hide palette
else hidePalette()
} else if (event.key === 'k' && (event.ctrlKey || event.metaKey)) {
togglePalette()
}
})
useOutsideClickHandler(boxRef, hidePalette)
return !user || !isPaletteShown ? null : (
<div className="palette__overlay">
<div className="palette__box card bg-dark" ref={boxRef}>
{(!activeFlow || activeFlow.instruction) && <CommandInput />}
{!commandSearchResults.length && !activeFlow ? null : <CommandResults executeResult={executeResult} />}
</div>
</div>
)
}

View File

@ -5,12 +5,14 @@ import { objectsEqual } from 'lib/utils'
export const compareFilterLogic = kea({
actions: () => ({
setCompare: (compare) => ({ compare }),
toggleCompare: true,
}),
reducers: ({ actions }) => ({
compare: [
false,
{
[actions.setCompare]: (_, { compare }) => compare,
[actions.toggleCompare]: (previousCompare) => !previousCompare,
},
],
}),

View File

@ -62,7 +62,11 @@ function CreateKeyModal({
}
function RowValue(value: string): JSX.Element {
return value ? <CopyToClipboardInline description="key value">{value}</CopyToClipboardInline> : <i>secret</i>
return value ? (
<CopyToClipboardInline description="personal API key value">{value}</CopyToClipboardInline>
) : (
<i>secret</i>
)
}
function RowActionsCreator(
@ -156,7 +160,7 @@ export function PersonalAPIKeys(): JSX.Element {
setIsCreateKeyModalVisible(true)
}}
>
+ Create a Personal API Key
+ Create Personal API Key
</Button>
<CreateKeyModal isVisible={isCreateKeyModalVisible} setIsVisible={setIsCreateKeyModalVisible} />
<PersonalAPIKeysTable />

View File

@ -3,6 +3,7 @@ import { toast } from 'react-toastify'
import api from 'lib/api'
import { PersonalAPIKeyType } from '~/types'
import { personalAPIKeysLogicType } from 'types/lib/components/PersonalAPIKeys/personalAPIKeysLogicType.ts'
import { copyToClipboard } from 'lib/utils'
export const personalAPIKeysLogic = kea<personalAPIKeysLogicType<PersonalAPIKeyType>>({
loaders: ({ values }) => ({
@ -24,10 +25,9 @@ export const personalAPIKeysLogic = kea<personalAPIKeysLogicType<PersonalAPIKeyT
},
],
}),
listeners: () => ({
createKeySuccess: ({ keys }: { keys: PersonalAPIKeyType[] }) => {
toast.success(`Personal API key "${keys[0].label}" created.`)
copyToClipboard(keys[0].value, 'personal API key value')
},
deleteKeySuccess: ({}: { keys: PersonalAPIKeyType[] }) => {
toast.success(`Personal API key deleted.`)

View File

@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react'
export function useEventListener(eventName, handler, element = window) {
// Create a ref that stores handler
const savedHandler = useRef()
// Update ref.current value if handler changes.
// This allows our effect below to always get latest handler ...
// ... without us needing to pass it in effect deps array ...
// ... and potentially cause effect to re-run every render.
useEffect(() => {
savedHandler.current = handler
}, [handler])
useEffect(
() => {
// Make sure element supports addEventListener
// On
const isSupported = element && element.addEventListener
if (!isSupported) return
// Create event listener that calls handler function stored in ref
const eventListener = (event) => savedHandler.current(event)
// Add event listener
element.addEventListener(eventName, eventListener)
// Remove event listener on cleanup
return () => {
element.removeEventListener(eventName, eventListener)
}
},
[eventName, element] // Re-run if eventName or element changes
)
}

View File

@ -0,0 +1,19 @@
import { useEffect, MutableRefObject } from 'react'
export function useOutsideClickHandler(
refOrRefs: MutableRefObject<Element> | MutableRefObject<Element>[],
handleClickOutside: () => void
): void {
useEffect(() => {
function handleClick(event: Event): void {
const handleCondition = Array.isArray(refOrRefs)
? !refOrRefs.some((ref) => ref.current?.contains(event.target as Node))
: !refOrRefs.current?.contains(event.target as Node)
if (handleCondition) handleClickOutside()
}
document.addEventListener('mousedown', handleClick)
return () => {
document.removeEventListener('mousedown', handleClick)
}
}, [refOrRefs, handleClickOutside])
}

View File

@ -354,7 +354,7 @@ export function stripHTTP(url) {
export function isURL(string) {
if (!string) return false
// https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
var expression = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi
var expression = /^\s*https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi
var regex = new RegExp(expression)
return string.match && string.match(regex)
}
@ -441,3 +441,19 @@ export function copyToClipboard(value, description) {
return false
}
}
export function clamp(value, min, max) {
return value > max ? max : value < min ? min : value
}
export function isMobile() {
return navigator.userAgent.includes('Mobile')
}
export function isMac() {
return navigator.platform.includes('Mac')
}
export function platformCommandControlKey() {
return isMac() ? '⌘' : 'Ctrl'
}

View File

@ -26,7 +26,11 @@ export const dashboardsModel = kea({
// We're not using this loader as a reducer per se, but just calling it `dashboard`
// to have the right payload ({ dashboard }) in the Success actions
dashboard: {
addDashboard: async ({ name }) => await api.create('api/dashboard', { name, pinned: true }),
addDashboard: async ({ name, show = false }) => {
const result = await api.create('api/dashboard', { name, pinned: true })
if (show) router.actions.push(`/dashboard/${result.id}`)
return result
},
renameDashboard: async ({ id, name }) => await api.update(`api/dashboard/${id}`, { name }),
setIsSharedDashboard: async ({ id, isShared }) =>
await api.update(`api/dashboard/${id}`, { is_shared: isShared }),
@ -74,7 +78,9 @@ export const dashboardsModel = kea({
dashboards: [
() => [selectors.rawDashboards],
(rawDashboards) => {
const list = Object.values(rawDashboards).sort((a, b) => a.name.localeCompare(b.name))
const list = Object.values(rawDashboards).sort((a, b) =>
(a.name ?? 'Untitled').localeCompare(b.name ?? 'Untitled')
)
return [...list.filter((d) => d.pinned), ...list.filter((d) => !d.pinned)]
},
],

View File

@ -16,6 +16,7 @@ import { userLogic } from 'scenes/userLogic'
import { sceneLogic, unauthenticatedRoutes } from 'scenes/sceneLogic'
import { SceneLoading } from 'lib/utils'
import { router } from 'kea-router'
import { CommandPalette } from 'lib/components/CommandPalette'
const darkerScenes = {
dashboard: true,
@ -86,28 +87,29 @@ function App() {
}
return (
<Layout className="bg-white">
<Sidebar user={user} sidebarCollapsed={sidebarCollapsed} setSidebarCollapsed={setSidebarCollapsed} />
<Layout
className={`${darkerScenes[scene] ? 'bg-dashboard' : 'bg-white'}${
!sidebarCollapsed ? ' with-open-sidebar' : ''
}`}
style={{ minHeight: '100vh' }}
>
<div className="content py-3 layout-top-content">
<>
<Layout className="bg-white">
<Sidebar user={user} sidebarCollapsed={sidebarCollapsed} setSidebarCollapsed={setSidebarCollapsed} />
<Layout
className={`${darkerScenes[scene] ? 'bg-dashboard' : 'bg-white'}${
!sidebarCollapsed ? ' with-open-sidebar' : ''
}`}
style={{ minHeight: '100vh' }}
>
<TopContent user={user} />
</div>
<Layout.Content className="pl-5 pr-5 pt-3" data-attr="layout-content">
<BillingToolbar />
{!user.has_events && image ? (
<SendEventsOverlay image={image} user={user} />
) : (
<Scene user={user} {...params} />
)}
<ToastContainer autoClose={8000} transition={Slide} position="bottom-center" />
</Layout.Content>
<Layout.Content className="pl-5 pr-5 pt-3" data-attr="layout-content">
<BillingToolbar />
{!user.has_events && image ? (
<SendEventsOverlay image={image} user={user} />
) : (
<Scene user={user} {...params} />
)}
<ToastContainer autoClose={8000} transition={Slide} position="bottom-center" />
</Layout.Content>
</Layout>
</Layout>
</Layout>
<CommandPalette />
</>
)
}

View File

@ -11,7 +11,10 @@ export const dashboardsLogic = kea({
selectors: () => ({
dashboards: [
() => [dashboardsModel.selectors.dashboards],
(dashboards) => dashboards.filter((d) => !d.deleted).sort((a, b) => a.name.localeCompare(b.name)),
(dashboards) =>
dashboards
.filter((d) => !d.deleted)
.sort((a, b) => (a.name ?? 'Untitled').localeCompare(b.name ?? 'Untitled')),
],
}),

View File

@ -1,17 +1,19 @@
import React from 'react'
import { useValues, useActions } from 'kea'
import { useValues, useActions, useMountedLogic } from 'kea'
import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters'
import { funnelLogic } from 'scenes/funnels/funnelLogic'
import { actionsModel } from '~/models/actionsModel'
import { userLogic } from 'scenes/userLogic'
import { ActionFilter } from '../ActionFilter/ActionFilter'
import { ActionFilter } from '../../ActionFilter/ActionFilter'
import { Link } from 'lib/components/Link'
import { Button, Row } from 'antd'
import { useState } from 'react'
import SaveModal from '../SaveModal'
import SaveModal from '../../SaveModal'
import { funnelCommandLogic } from './funnelCommandLogic'
export function FunnelTab(): JSX.Element {
useMountedLogic(funnelCommandLogic)
const { isStepsEmpty, filters, stepsWithCount } = useValues(funnelLogic)
const { loadFunnel, clearFunnel, setFilters, saveFunnelInsight } = useActions(funnelLogic)
const { actions, actionsLoading } = useValues(actionsModel)

View File

@ -0,0 +1,48 @@
import { kea } from 'kea'
import {
Command,
commandPaletteLogic,
CommandRegistrations,
CommandResult,
CommandResultTemplate,
CommandFlow,
RegExpCommandPairs,
} from 'lib/components/CommandPalette/commandPaletteLogic'
import { funnelLogic } from 'scenes/funnels/funnelLogic'
import { commandPaletteLogicType } from 'types/lib/components/CommandPalette/commandPaletteLogicType'
import { FunnelPlotOutlined } from '@ant-design/icons'
const FUNNEL_COMMAND_SCOPE = 'funnels'
export const funnelCommandLogic = kea<
commandPaletteLogicType<Command, CommandRegistrations, CommandResult, CommandFlow, RegExpCommandPairs>
>({
connect: [commandPaletteLogic],
events: () => ({
afterMount: () => {
const results: CommandResultTemplate[] = [
{
icon: FunnelPlotOutlined,
display: 'Clear Funnel',
executor: () => {
funnelLogic.actions.clearFunnel()
},
},
]
const funnelCommands: Command[] = [
{
key: FUNNEL_COMMAND_SCOPE,
resolver: results,
scope: FUNNEL_COMMAND_SCOPE,
},
]
for (const command of funnelCommands) {
commandPaletteLogic.actions.registerCommand(command)
}
},
beforeUnmount: () => {
commandPaletteLogic.actions.deregisterScope(FUNNEL_COMMAND_SCOPE)
},
}),
})

View File

@ -1,14 +1,14 @@
import React from 'react'
import { useValues, useActions } from 'kea'
import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters'
import { ActionFilter } from '../ActionFilter/ActionFilter'
import { ActionFilter } from '../../ActionFilter/ActionFilter'
import { Tooltip, Row } from 'antd'
import { BreakdownFilter } from '../BreakdownFilter'
import { BreakdownFilter } from '../../BreakdownFilter'
import { CloseButton } from 'lib/utils'
import { ShownAsFilter } from '../ShownAsFilter'
import { ShownAsFilter } from '../../ShownAsFilter'
import { InfoCircleOutlined } from '@ant-design/icons'
import { trendsLogic } from '../trendsLogic'
import { ViewType } from '../insightLogic'
import { trendsLogic } from '../../trendsLogic'
import { ViewType } from '../../insightLogic'
export function TrendTab(): JSX.Element {
const { filters } = useValues(trendsLogic({ dashboardItemId: null, view: ViewType.TRENDS }))

View File

@ -1,5 +1,5 @@
export * from './RetentionTab'
export * from './SessionTab'
export * from './TrendTab'
export * from './TrendTab/TrendTab'
export * from './PathTab'
export * from './FunnelTab'
export * from './FunnelTab/FunnelTab'

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { useActions, useValues } from 'kea'
import { useActions, useMountedLogic, useValues } from 'kea'
import { Card, Loading } from 'lib/utils'
import { SaveToDashboard } from 'lib/components/SaveToDashboard/SaveToDashboard'
@ -44,6 +44,7 @@ import { CompareFilter } from 'lib/components/CompareFilter/CompareFilter'
import { InsightHistoryPanel } from './InsightHistoryPanel'
import { SavedFunnels } from './SavedCard'
import { InfoCircleOutlined } from '@ant-design/icons'
import { insightCommandLogic } from './insightCommandLogic'
const { TabPane } = Tabs
@ -110,6 +111,7 @@ function determineInsightType(activeView, display) {
export const Insights = hot(_Insights)
function _Insights() {
useMountedLogic(insightCommandLogic)
const [{ fromItem }] = useState(router.values.hashParams)
const { clearAnnotationsToCreate } = useActions(annotationsLogic({ pageKey: fromItem }))
const { annotationsToCreate } = useValues(annotationsLogic({ pageKey: fromItem }))

View File

@ -0,0 +1,54 @@
import {
Command,
commandPaletteLogic,
CommandRegistrations,
CommandResult,
CommandFlow,
RegExpCommandPairs,
} from 'lib/components/CommandPalette/commandPaletteLogic'
import { commandPaletteLogicType } from 'types/lib/components/CommandPalette/commandPaletteLogicType'
import { kea } from 'kea'
import { compareFilterLogic } from 'lib/components/CompareFilter/compareFilterLogic'
import { RiseOutlined } from '@ant-design/icons'
import { dateFilterLogic } from 'lib/components/DateFilter/dateFilterLogic'
import { dateMapping } from 'lib/utils'
const INSIGHT_COMMAND_SCOPE = 'insights'
export const insightCommandLogic = kea<
commandPaletteLogicType<Command, CommandRegistrations, CommandResult, CommandFlow, RegExpCommandPairs>
>({
connect: [commandPaletteLogic, compareFilterLogic, dateFilterLogic],
events: () => ({
afterMount: () => {
const funnelCommands: Command[] = [
{
key: 'insight-graph',
resolver: [
{
icon: RiseOutlined,
display: 'Toggle "Compare Previous" on Graph',
executor: () => {
compareFilterLogic.actions.toggleCompare()
},
},
...Object.entries(dateMapping).map(([key, value]) => ({
icon: RiseOutlined,
display: `Set Time Range to ${key}`,
executor: () => {
dateFilterLogic.actions.setDates(value[0], value[1])
},
})),
],
scope: INSIGHT_COMMAND_SCOPE,
},
]
for (const command of funnelCommands) {
commandPaletteLogic.actions.registerCommand(command)
}
},
beforeUnmount: () => {
commandPaletteLogic.actions.deregisterScope(INSIGHT_COMMAND_SCOPE)
},
}),
})

View File

@ -89,7 +89,6 @@ function _People() {
<input
className="form-control"
name="search"
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.keyCode === 13 && fetchPeople()}

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import { Events } from '../events/Events'
import api from 'lib/api'
import { router } from 'kea-router'
import { PersonTable } from './PersonTable'
import { deletePersonData, savePersonData } from 'lib/utils'
import { changeType } from 'lib/utils/changeType'
@ -16,19 +17,24 @@ export const Person = hot(_Person)
function _Person({ _: distinctId, id }) {
const { innerWidth } = window
const isScreenSmall = innerWidth < 700
const { push } = router.actions
const [person, setPerson] = useState(null)
const [personChanged, setPersonChanged] = useState(false)
const [activeTab, setActiveTab] = useState('events')
useEffect(() => {
let url = ''
if (distinctId) {
url = `api/person/by_distinct_id/?distinct_id=${distinctId}`
api.get(`api/person/?distinct_id=${distinctId}`).then((response) => {
if (response.results.length > 0) {
setPerson(response.results[0])
} else {
push('/404')
}
})
} else {
url = `api/person/${id}`
api.get(`api/person/${id}`).then(setPerson)
}
api.get(url).then(setPerson)
}, [distinctId, id])
function _handleChange(event) {

View File

@ -88,6 +88,7 @@ $body-color: #37352f;
box-shadow: 0px 0px 13px rgba(0, 0, 0, 0.1);
border: 0;
margin-bottom: 3rem;
overflow: hidden;
.card-header {
font-weight: 500;
font-size: 15px;
@ -455,6 +456,10 @@ label.disabled {
background-color: #f0f0f0 !important;
}
.btn-sm {
line-height: 20px;
}
@media screen and (max-width: 480px) {
h1.title {
line-height: 1.1em;

View File

@ -46,7 +46,7 @@ export const actionsLogic = kea<actionsLogicType<ActionType>>({
(s) => [s.allActions],
(allActions) =>
[...allActions].sort((a, b) =>
(a.name || 'Untitled').localeCompare(b.name || 'Untitled')
(a.name ?? 'Untitled').localeCompare(b.name ?? 'Untitled')
) as ActionType[],
],
actionCount: [(s) => [s.sortedActions], (sortedActions) => sortedActions.length],

View File

@ -172,3 +172,15 @@ export interface BillingSubscription {
subscription_url: string
stripe_checkout_session: string
}
export interface DashboardType {
id: number
name: string
pinned: string
items: []
created_at: string
created_by: number
is_shared: boolean
share_token: string
deleted: boolean
}

View File

@ -63,3 +63,6 @@ ignore_missing_imports = True
[mypy-clickhouse_driver.errors]
ignore_missing_imports = True
[mypy-django_filters]
ignore_missing_imports = True

View File

@ -30,6 +30,7 @@
"@mariusandra/query-selector-shadow-dom": "0.7.2-posthog.2",
"@mariusandra/react-grid-layout": "0.18.3",
"@mariusandra/simmerjs": "0.7.1-posthog.1",
"@types/lodash": "^4.14.162",
"@types/react-syntax-highlighter": "^11.0.4",
"@types/zxcvbn": "^4.4.0",
"antd": "^4.1.1",
@ -41,12 +42,15 @@
"d3-sankey": "^0.12.3",
"editor": "^1.0.0",
"eslint-plugin-cypress": "^2.11.1",
"expr-eval": "^2.0.2",
"funnel-graph-js": "^1.4.1",
"fuse.js": "^6.4.1",
"kea": "^2.2.0",
"kea-loaders": "^0.3.0",
"kea-localstorage": "^1.0.2",
"kea-router": "^0.4.0",
"kea-window-values": "^0.0.1",
"lodash": "^4.17.20",
"moment": "^2.24.0",
"posthog-js": "1.5.0-beta.0",
"posthog-js-lite": "^0.0.3",
@ -62,10 +66,9 @@
"react-toastify": "^5.5.0",
"redux": "^4.0.5",
"reselect": "^4.0.0",
"rrweb-player": "^0.6.2",
"rrweb": "^0.9.7",
"rrweb-player": "^0.6.2",
"sass": "^1.26.2",
"styled-components": "^5.0.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {

View File

@ -1,18 +1,19 @@
import json
from typing import Any, Dict, List, Union
import warnings
from typing import Any, Dict, List
from django.core.cache import cache
from django.db.models import Count, Func, OuterRef, Prefetch, Q, QuerySet, Subquery
from django.db.models import Count, Func, Prefetch, Q, QuerySet
from django_filters import rest_framework as filters
from rest_framework import request, response, serializers, viewsets
from rest_framework.decorators import action
from rest_framework.settings import api_settings
from rest_framework_csv import renderers as csvrenderers # type: ignore
from posthog.models import Cohort, Event, Filter, Person, PersonDistinctId, Team
from posthog.models import Event, Filter, Person, Team
from posthog.utils import convert_property_value
from .base import CursorPagination as BaseCursorPagination
from .event import EventSerializer
class PersonSerializer(serializers.HyperlinkedModelSerializer):
@ -41,11 +42,25 @@ class CursorPagination(BaseCursorPagination):
page_size = 100
class PersonFilter(filters.FilterSet):
email = filters.CharFilter(field_name="properties__email")
distinct_id = filters.CharFilter(field_name="persondistinctid__distinct_id")
key_identifier = filters.CharFilter(method="key_identifier_filter")
def key_identifier_filter(self, queryset, attr, *args, **kwargs):
"""
Filters persons by email or distinct ID
"""
return queryset.filter(Q(persondistinctid__distinct_id=args[0]) | Q(properties__email=args[0]))
class PersonViewSet(viewsets.ModelViewSet):
renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (csvrenderers.PaginatedCSVRenderer,)
queryset = Person.objects.all()
serializer_class = PersonSerializer
pagination_class = CursorPagination
filter_backends = [filters.DjangoFilterBackend]
filterset_class = PersonFilter
def paginate_queryset(self, queryset):
if self.request.accepted_renderer.format == "csv" or not self.paginator:
@ -103,6 +118,12 @@ class PersonViewSet(viewsets.ModelViewSet):
@action(methods=["GET"], detail=False)
def by_distinct_id(self, request):
"""
DEPRECATED in favor of /api/person/?distinct_id={id}
"""
warnings.warn(
"/api/person/by_distinct_id/ endpoint is deprecated; use /api/person/ instead.", DeprecationWarning,
)
result = self.get_by_distinct_id(request)
return response.Response(result)
@ -110,6 +131,21 @@ class PersonViewSet(viewsets.ModelViewSet):
person = self.get_queryset().get(persondistinctid__distinct_id=str(request.GET["distinct_id"]))
return PersonSerializer(person).data
@action(methods=["GET"], detail=False)
def by_email(self, request):
"""
DEPRECATED in favor of /api/person/?email={email}
"""
warnings.warn(
"/api/person/by_email/ endpoint is deprecated; use /api/person/ instead.", DeprecationWarning,
)
result = self.get_by_email(request)
return response.Response(result)
def get_by_email(self, request):
person = self.get_queryset().get(properties__email=str(request.GET["email"]))
return PersonSerializer(person).data
@action(methods=["GET"], detail=False)
def properties(self, request: request.Request) -> response.Response:
result = self.get_properties(request)

View File

@ -61,22 +61,24 @@ class APIBaseTest(APITestCase, ErrorResponsesMixin):
Test API using Django REST Framework test suite.
"""
TESTS_COMPANY_NAME: str = "Test"
TESTS_EMAIL: Optional[str] = "user1@posthog.com"
TESTS_PASSWORD: Optional[str] = "testpassword12345"
TESTS_API_TOKEN: str = "token123"
TESTS_FORCE_LOGIN: bool = True
CONFIG_ORGANIZATION_NAME: str = "Test"
CONFIG_USER_EMAIL: Optional[str] = "user1@posthog.com"
CONFIG_PASSWORD: Optional[str] = "testpassword12345"
CONFIG_API_TOKEN: str = "token123"
CONFIG_AUTO_LOGIN: bool = True
def _create_user(self, email: str, password: Optional[str] = None, **kwargs) -> User:
return User.objects.create_and_join(
organization=self.organization, team=self.team, email=email, password=password, **kwargs
organization=self.organization, team=self.team, email=email, password=password, **kwargs,
)
def setUp(self):
super().setUp()
self.organization: Organization = Organization.objects.create(name=self.TESTS_COMPANY_NAME)
self.team: Team = Team.objects.create(organization=self.organization, api_token=self.TESTS_API_TOKEN)
if self.TESTS_EMAIL:
self.user = self._create_user(self.TESTS_EMAIL, self.TESTS_PASSWORD)
if self.TESTS_FORCE_LOGIN:
self.organization: Organization = Organization.objects.create(name=self.CONFIG_ORGANIZATION_NAME)
self.team: Team = Team.objects.create(organization=self.organization, api_token=self.CONFIG_API_TOKEN)
if self.CONFIG_USER_EMAIL:
self.user = self._create_user(self.CONFIG_USER_EMAIL, self.CONFIG_PASSWORD)
if self.CONFIG_AUTO_LOGIN:
self.client.force_login(self.user)

View File

@ -1,17 +1,16 @@
import json
from django.utils import timezone
from rest_framework import status
from posthog.models import Cohort, Event, Person
from posthog.models import Cohort, Event, Organization, Person, Team
from posthog.tasks.process_event import process_event
from .base import BaseTest
from .base import APIBaseTest
class TestPerson(BaseTest):
TESTS_API = True
def test_search(self):
class TestPerson(APIBaseTest):
def test_search(self) -> None:
Person.objects.create(
team=self.team, distinct_ids=["distinct_id"], properties={"email": "someone@gmail.com"},
)
@ -20,16 +19,19 @@ class TestPerson(BaseTest):
)
Person.objects.create(team=self.team, distinct_ids=["distinct_id_3"], properties={})
response = self.client.get("/api/person/?search=has:email").json()
self.assertEqual(len(response["results"]), 2)
response = self.client.get("/api/person/?search=has:email")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 2)
response = self.client.get("/api/person/?search=another@gm").json()
self.assertEqual(len(response["results"]), 1)
response = self.client.get("/api/person/?search=another@gm")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 1)
response = self.client.get("/api/person/?search=_id_3").json()
self.assertEqual(len(response["results"]), 1)
response = self.client.get("/api/person/?search=_id_3")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 1)
def test_properties(self):
def test_properties(self) -> None:
Person.objects.create(
team=self.team, distinct_ids=["distinct_id"], properties={"email": "someone@gmail.com"},
)
@ -40,27 +42,31 @@ class TestPerson(BaseTest):
response = self.client.get(
"/api/person/?properties=%s" % json.dumps([{"key": "email", "operator": "is_set", "value": "is_set"}])
).json()
self.assertEqual(len(response["results"]), 2)
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 2)
response = self.client.get(
"/api/person/?properties=%s"
% json.dumps([{"key": "email", "operator": "icontains", "value": "another@gm"}])
).json()
self.assertEqual(len(response["results"]), 1)
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 1)
def test_person_property_names(self):
def test_person_property_names(self) -> None:
Person.objects.create(team=self.team, properties={"$browser": "whatever", "$os": "Mac OS X"})
Person.objects.create(team=self.team, properties={"random_prop": "asdf"})
Person.objects.create(team=self.team, properties={"random_prop": "asdf"})
response = self.client.get("/api/person/properties/").json()
self.assertEqual(response[0]["name"], "random_prop")
self.assertEqual(response[0]["count"], 2)
self.assertEqual(response[2]["name"], "$os")
self.assertEqual(response[2]["count"], 1)
self.assertEqual(response[1]["name"], "$browser")
self.assertEqual(response[1]["count"], 1)
response = self.client.get("/api/person/properties/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
response_data = response.json()
self.assertEqual(response_data[0]["name"], "random_prop")
self.assertEqual(response_data[0]["count"], 2)
self.assertEqual(response_data[2]["name"], "$os")
self.assertEqual(response_data[2]["count"], 1)
self.assertEqual(response_data[1]["name"], "$browser")
self.assertEqual(response_data[1]["count"], 1)
def test_person_property_values(self):
Person.objects.create(
@ -69,16 +75,19 @@ class TestPerson(BaseTest):
Person.objects.create(team=self.team, properties={"random_prop": "asdf"})
Person.objects.create(team=self.team, properties={"random_prop": "qwerty"})
Person.objects.create(team=self.team, properties={"something_else": "qwerty"})
response = self.client.get("/api/person/values/?key=random_prop").json()
self.assertEqual(response[0]["name"], "asdf")
self.assertEqual(response[0]["count"], 2)
self.assertEqual(response[1]["name"], "qwerty")
self.assertEqual(response[1]["count"], 1)
self.assertEqual(len(response), 2)
response = self.client.get("/api/person/values/?key=random_prop")
self.assertEqual(response.status_code, status.HTTP_200_OK)
response_data = response.json()
self.assertEqual(response_data[0]["name"], "asdf")
self.assertEqual(response_data[0]["count"], 2)
self.assertEqual(response_data[1]["name"], "qwerty")
self.assertEqual(response_data[1]["count"], 1)
self.assertEqual(len(response_data), 2)
response = self.client.get("/api/person/values/?key=random_prop&value=qw").json()
self.assertEqual(response[0]["name"], "qwerty")
self.assertEqual(response[0]["count"], 1)
response = self.client.get("/api/person/values/?key=random_prop&value=qw")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()[0]["name"], "qwerty")
self.assertEqual(response.json()[0]["count"], 1)
def test_filter_by_cohort(self):
Person.objects.create(
@ -88,28 +97,110 @@ class TestPerson(BaseTest):
cohort = Cohort.objects.create(team=self.team, groups=[{"properties": {"$os": "Chrome"}}])
cohort.calculate_people()
response = self.client.get("/api/person/?cohort=%s" % cohort.pk).json()
self.assertEqual(len(response["results"]), 1, response)
response = self.client.get(f"/api/person/?cohort={cohort.pk}")
self.assertEqual(len(response.json()["results"]), 1, response)
def test_filter_person_list(self):
person1: Person = Person.objects.create(
team=self.team, distinct_ids=["distinct_id", "another_one"], properties={"email": "someone@gmail.com"},
)
person2: Person = Person.objects.create(
team=self.team, distinct_ids=["distinct_id_2"], properties={"email": "another@gmail.com"},
)
# Filter by distinct ID
response = self.client.get("/api/person/?distinct_id=distinct_id") # must be exact matches
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 1)
self.assertEqual(response.json()["results"][0]["id"], person1.pk)
response = self.client.get("/api/person/?distinct_id=another_one") # can search on any of the distinct IDs
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 1)
self.assertEqual(response.json()["results"][0]["id"], person1.pk)
# Filter by email
response = self.client.get("/api/person/?email=another@gmail.com")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 1)
self.assertEqual(response.json()["results"][0]["id"], person2.pk)
# Filter by key identifier
for _identifier in ["another@gmail.com", "distinct_id_2"]:
response = self.client.get(f"/api/person/?key_identifier={_identifier}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 1)
self.assertEqual(response.json()["results"][0]["id"], person2.pk)
# Non-matches return an empty list
response = self.client.get("/api/person/?email=inexistent")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 0)
response = self.client.get("/api/person/?distinct_id=inexistent")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 0)
def test_cant_see_another_orgs_pii_with_filters(self):
another_org: Organization = Organization.objects.create()
another_team: Team = Team.objects.create(organization=self.organization)
another_person1: Person = Person.objects.create(
team=another_team, distinct_ids=["distinct_id", "x_another_one"],
)
another_person2: Person = Person.objects.create(
team=another_team, distinct_ids=["x_distinct_id_2"], properties={"email": "team2_another@gmail.com"},
)
person: Person = Person.objects.create(
team=self.team, distinct_ids=["distinct_id"],
)
# Filter by distinct ID
response = self.client.get("/api/person/?distinct_id=distinct_id")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 1)
self.assertEqual(
response.json()["results"][0]["id"], person.pk
) # note only the person from the same team is returned
response = self.client.get("/api/person/?distinct_id=x_another_one")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["results"], [])
# Filter by email
response = self.client.get("/api/person/?email=team2_another@gmail.com")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["results"], [])
# Filter by key identifier
for _identifier in ["x_another_one", "distinct_id_2"]:
response = self.client.get(f"/api/person/?key_identifier={_identifier}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["results"], [])
def test_category_param(self):
person_anonymous = Person.objects.create(team=self.team, distinct_ids=["xyz"])
person_identified_already = Person.objects.create(team=self.team, distinct_ids=["tuv"], is_identified=True,)
person_identified_using_event = Person.objects.create(team=self.team, distinct_ids=["klm"],)
person_identified_already = Person.objects.create(team=self.team, distinct_ids=["tuv"], is_identified=True)
person_identified_using_event = Person.objects.create(team=self.team, distinct_ids=["klm"])
# all
response = self.client.get(
"/api/person"
).json() # Make sure the endpoint works with and without the trailing slash
self.assertEqual(len(response["results"]), 3)
response_all = self.client.get("/api/person/?category=all").json()
self.assertListEqual(response["results"], response_all["results"])
response = self.client.get("/api/person") # Make sure the endpoint works with and without the trailing slash
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 3)
response_all = self.client.get("/api/person/?category=all")
self.assertEqual(response_all.status_code, status.HTTP_200_OK)
self.assertListEqual(response.json()["results"], response_all.json()["results"])
# person_identified_using_event should have is_identified set to True after an $identify event
process_event(
person_identified_using_event.distinct_ids[0],
"",
"",
{"event": "$identify",},
{"event": "$identify"},
self.team.pk,
timezone.now().isoformat(),
timezone.now().isoformat(),
@ -117,15 +208,17 @@ class TestPerson(BaseTest):
self.assertTrue(Person.objects.get(team_id=self.team.id, persondistinctid__distinct_id="klm").is_identified)
# anonymous
response_anonymous = self.client.get("/api/person/?category=anonymous").json()
self.assertEqual(len(response_anonymous["results"]), 1)
self.assertEqual(response_anonymous["results"][0]["id"], person_anonymous.id)
response = self.client.get("/api/person/?category=anonymous")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 1)
self.assertEqual(response.json()["results"][0]["id"], person_anonymous.id)
# identified
response_identified = self.client.get("/api/person/?category=identified").json()
self.assertEqual(len(response_identified["results"]), 2)
self.assertEqual(response_identified["results"][0]["id"], person_identified_using_event.id)
self.assertEqual(response_identified["results"][1]["id"], person_identified_already.id)
response = self.client.get("/api/person/?category=identified")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["results"]), 2)
self.assertEqual(response.json()["results"][0]["id"], person_identified_using_event.id)
self.assertEqual(response.json()["results"][1]["id"], person_identified_already.id)
def test_delete_person(self):
person = Person.objects.create(
@ -135,12 +228,31 @@ class TestPerson(BaseTest):
Event.objects.create(team=self.team, distinct_id="anonymous_id")
Event.objects.create(team=self.team, distinct_id="someone_else")
response = self.client.delete("/api/person/%s/" % person.pk)
response = self.client.delete(f"/api/person/{person.pk}/")
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(response.data, None)
self.assertEqual(Person.objects.count(), 0)
self.assertEqual(Event.objects.count(), 1)
def test_person_is_identified(self):
person_identified = Person.objects.create(team=self.team, is_identified=True)
person_anonymous = Person.objects.create(team=self.team)
self.assertEqual(person_identified.is_identified, True)
self.assertEqual(person_anonymous.is_identified, False)
def test_filters_by_endpoints_are_deprecated(self):
Person.objects.create(
team=self.team, distinct_ids=["person_1"], properties={"email": "someone@gmail.com"},
)
# By Distinct ID
with self.assertWarns(DeprecationWarning) as warnings:
response = self.client.get("/api/person/by_distinct_id/?distinct_id=person_1")
self.assertEqual(response.status_code, status.HTTP_200_OK) # works but it's deprecated
self.assertEqual(
str(warnings.warning), "/api/person/by_distinct_id/ endpoint is deprecated; use /api/person/ instead.",
)
# By Distinct ID
with self.assertWarns(DeprecationWarning) as warnings:
response = self.client.get("/api/person/by_email/?email=someone@gmail.com")
self.assertEqual(response.status_code, status.HTTP_200_OK) # works but it's deprecated
self.assertEqual(
str(warnings.warning), "/api/person/by_email/ endpoint is deprecated; use /api/person/ instead.",
)

View File

@ -14,7 +14,7 @@ class TestTeamUser(APIBaseTest):
def create_user_for_team(self, team: Team, organization: Organization) -> User:
suffix = random.randint(100000, 999999)
user = User.objects.create_and_join(
organization, team, f"user{suffix}@posthog.com", self.TESTS_PASSWORD, first_name=f"User #{suffix}",
organization, team, f"user{suffix}@posthog.com", self.CONFIG_PASSWORD, first_name=f"User #{suffix}",
)
return user
@ -175,7 +175,7 @@ class TestTeamUser(APIBaseTest):
class TestTeamSignup(APIBaseTest):
TESTS_EMAIL = None
CONFIG_USER_EMAIL = None
@tag("skip_on_multitenancy")
@patch("posthog.api.team.settings.EE_AVAILABLE", False)

View File

@ -195,6 +195,7 @@ INSTALLED_APPS = [
"loginas",
"corsheaders",
"social_django",
"django_filters",
]
@ -401,7 +402,7 @@ REST_FRAMEWORK = {
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated",],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"EXCEPTION_HANDLER": "exceptions_hog.exception_handler",
"PAGE_SIZE": 100,
}

View File

@ -21,30 +21,4 @@
}
</script>
{% endif %}
{% if debug or request.get_host == 'app.posthog.com' %}
<script>
window.Papercups = {
config: {
accountId: '873f5102-d267-4b09-9de0-d6e741e0e076',
title: 'Welcome to PostHog',
subtitle: 'Ask us anything in the chat window below 😊',
primaryColor: '#1890ff',
greeting: "Hi! Send us a message and we'll respond as soon as we can.",
customer: {
email: '{{ request.user.email }}',
name: '{{ request.user.first_name }}'
},
newMessagePlaceholder: 'Start typing…',
baseUrl: 'https://app.papercups.io'
},
};
</script>
<script
type="text/javascript"
async
defer
src="https://app.papercups.io/widget.js"
></script>
{% endif %}
{% endif %}

View File

@ -8,15 +8,15 @@ from posthog.models import Action, ActionStep, Cohort, Event, Person
class TestPerson(BaseTest):
def test_merge_people(self):
person0 = Person.objects.create(distinct_ids=["person_0"], team=self.team, properties={"$os": "Microsoft"},)
person0 = Person.objects.create(distinct_ids=["person_0"], team=self.team, properties={"$os": "Microsoft"})
person0.created_at = datetime.datetime(2020, 1, 1, tzinfo=pytz.UTC)
person0.save()
person1 = Person.objects.create(distinct_ids=["person_1"], team=self.team, properties={"$os": "Chrome"},)
person1 = Person.objects.create(distinct_ids=["person_1"], team=self.team, properties={"$os": "Chrome"})
person1.created_at = datetime.datetime(2019, 7, 1, tzinfo=pytz.UTC)
person1.save()
event1 = Event.objects.create(event="user signed up", team=self.team, distinct_id="person_1")
Event.objects.create(event="user signed up", team=self.team, distinct_id="person_1")
action = Action.objects.create(team=self.team)
ActionStep.objects.create(action=action, event="user signed up")
action.calculate_events()
@ -43,4 +43,10 @@ class TestPerson(BaseTest):
self.assertEqual(
person0.created_at, datetime.datetime(2019, 7, 1, tzinfo=pytz.UTC),
) # oldest time is kept
) # oldest created_at is kept
def test_person_is_identified(self):
person_identified = Person.objects.create(team=self.team, is_identified=True)
person_anonymous = Person.objects.create(team=self.team)
self.assertEqual(person_identified.is_identified, True)
self.assertEqual(person_anonymous.is_identified, False)

View File

@ -22,6 +22,7 @@ dj-database-url==0.5.0
Django==3.0.7
django-cors-headers==3.2.1
django-extensions==2.2.9
django-filter==2.4.0
django-loginas==0.3.8
django-redis==4.12.1
django-statsd==2.5.2

View File

@ -155,6 +155,20 @@ function createEntry(entry) {
},
],
},
{
// Apply rule for sound files
test: /\.(mp3)$/,
use: [
{
// Using file-loader too
loader: 'file-loader',
options: {
name: '[name].[contenthash].[ext]',
outputPath: 'sounds',
},
},
],
},
],
},
devServer: {

View File

@ -96,7 +96,7 @@
jsesc "^2.5.1"
source-map "^0.6.1"
"@babel/helper-annotate-as-pure@^7.0.0", "@babel/helper-annotate-as-pure@^7.10.4":
"@babel/helper-annotate-as-pure@^7.10.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3"
integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==
@ -885,7 +885,7 @@
"@babel/parser" "^7.10.4"
"@babel/types" "^7.10.4"
"@babel/traverse@^7.10.4", "@babel/traverse@^7.11.5", "@babel/traverse@^7.4.5", "@babel/traverse@^7.7.0":
"@babel/traverse@^7.10.4", "@babel/traverse@^7.11.5", "@babel/traverse@^7.7.0":
version "7.11.5"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.11.5.tgz#be777b93b518eb6d76ee2e1ea1d143daa11e61c3"
integrity sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ==
@ -953,28 +953,6 @@
debug "^3.1.0"
lodash.once "^4.1.1"
"@emotion/is-prop-valid@^0.8.8":
version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==
dependencies:
"@emotion/memoize" "0.7.4"
"@emotion/memoize@0.7.4":
version "0.7.4"
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
"@emotion/stylis@^0.8.4":
version "0.8.5"
resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.8.5.tgz#deacb389bd6ee77d1e7fcaccce9e16c5c7e78e04"
integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==
"@emotion/unitless@^0.7.4":
version "0.7.5"
resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed"
integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
"@eslint/eslintrc@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.1.0.tgz#3d1f19fb797d42fb1c85458c1c73541eeb1d9e76"
@ -1132,6 +1110,11 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==
"@types/lodash@^4.14.162":
version "4.14.162"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.162.tgz#65d78c397e0d883f44afbf1f7ba9867022411470"
integrity sha512-alvcho1kRUnnD1Gcl4J+hK0eencvzq9rmzvFPRmP5rPHx9VVsJj6bKLTATPVf9ktgv4ujzh7T+XWKp+jhuODig==
"@types/minimatch@*":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@ -1910,21 +1893,6 @@ babel-plugin-kea@^0.1.0:
resolved "https://registry.yarnpkg.com/babel-plugin-kea/-/babel-plugin-kea-0.1.0.tgz#e48f50d1981082b53111a3f59c3abef9a6c3e51e"
integrity sha512-vZLD1yM4BLAFwJKIMPyWezljsqsp1OWsW8vQBW57G+d1k2hODwd3wtXD/f/j7aXDxeV/v2a7hHDN2UH8QyapPA==
"babel-plugin-styled-components@>= 1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/babel-plugin-styled-components/-/babel-plugin-styled-components-1.11.1.tgz#5296a9e557d736c3186be079fff27c6665d63d76"
integrity sha512-YwrInHyKUk1PU3avIRdiLyCpM++18Rs1NgyMXEAQC33rIXs/vro0A+stf4sT0Gf22Got+xRWB8Cm0tw+qkRzBA==
dependencies:
"@babel/helper-annotate-as-pure" "^7.0.0"
"@babel/helper-module-imports" "^7.0.0"
babel-plugin-syntax-jsx "^6.18.0"
lodash "^4.17.11"
babel-plugin-syntax-jsx@^6.18.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
integrity sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY=
babel-preset-nano-react-app@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/babel-preset-nano-react-app/-/babel-preset-nano-react-app-0.1.0.tgz#1e33f12e96f7ec9a66de44b5702c8f7c46111f93"
@ -2305,11 +2273,6 @@ camelcase@^5.0.0, camelcase@^5.3.1:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
camelize@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=
caniuse-api@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
@ -2966,11 +2929,6 @@ crypto-random-string@^2.0.0:
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
css-color-keywords@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05"
integrity sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU=
css-color-names@0.0.4, css-color-names@^0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
@ -3036,15 +2994,6 @@ css-select@^2.0.0:
domutils "^1.7.0"
nth-check "^1.0.2"
css-to-react-native@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.0.0.tgz#62dbe678072a824a689bcfee011fc96e02a7d756"
integrity sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==
dependencies:
camelize "^1.0.0"
css-color-keywords "^1.0.0"
postcss-value-parser "^4.0.2"
css-tree@1.0.0-alpha.37:
version "1.0.0-alpha.37"
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.37.tgz#98bebd62c4c1d9f960ec340cf9f7522e30709a22"
@ -4238,6 +4187,11 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
dependencies:
homedir-polyfill "^1.0.1"
expr-eval@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/expr-eval/-/expr-eval-2.0.2.tgz#fa6f044a7b0c93fde830954eb9c5b0f7fbc7e201"
integrity sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==
express@^4.17.1:
version "4.17.1"
resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
@ -4632,6 +4586,11 @@ funnel-graph-js@^1.4.1:
resolved "https://registry.yarnpkg.com/funnel-graph-js/-/funnel-graph-js-1.4.2.tgz#b82150189e8afa59104d881d5dcf55a28d715342"
integrity sha512-9bnmcBve7RDH9dTF9BLuUpuisKkDka3yrfhs+Z/106ZgJvqIse1RfKQWjW+QdAlTrZqC9oafen7t/KuJKv9ohA==
fuse.js@^6.4.1:
version "6.4.1"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.4.1.tgz#76f1b4ab9cd021b854a68381b35628033d27507e"
integrity sha512-+hAS7KYgLXontDh/vqffs7wIBw0ceb9Sx8ywZQhOsiQGcSO5zInGhttWOUYQYlvV/yYMJOacQ129Xs3mP3+oZQ==
gensync@^1.0.0-beta.1:
version "1.0.0-beta.1"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269"
@ -4959,7 +4918,7 @@ hmac-drbg@^1.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@ -9459,22 +9418,6 @@ style-loader@^1.2.1:
loader-utils "^2.0.0"
schema-utils "^2.6.6"
styled-components@^5.0.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.1.1.tgz#96dfb02a8025794960863b9e8e365e3b6be5518d"
integrity sha512-1ps8ZAYu2Husx+Vz8D+MvXwEwvMwFv+hqqUwhNlDN5ybg6A+3xyW1ECrAgywhvXapNfXiz79jJyU0x22z0FFTg==
dependencies:
"@babel/helper-module-imports" "^7.0.0"
"@babel/traverse" "^7.4.5"
"@emotion/is-prop-valid" "^0.8.8"
"@emotion/stylis" "^0.8.4"
"@emotion/unitless" "^0.7.4"
babel-plugin-styled-components ">= 1"
css-to-react-native "^3.0.0"
hoist-non-react-statics "^3.0.0"
shallowequal "^1.1.0"
supports-color "^5.5.0"
stylehacks@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5"