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:
parent
72fe8fdff9
commit
f71e011a86
17
cypress/integration/commandPalette.js
Normal file
17
cypress/integration/commandPalette.js
Normal 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')
|
||||
})
|
||||
})
|
20
cypress/integration/featureFlags.js
Normal file
20
cypress/integration/featureFlags.js
Normal 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')
|
||||
})
|
||||
})
|
20
cypress/integration/liveActions.js
Normal file
20
cypress/integration/liveActions.js
Normal 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')
|
||||
})
|
||||
})
|
123
cypress/integration/trendsElements.js
Normal file
123
cypress/integration/trendsElements.js
Normal 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')
|
||||
})
|
||||
})
|
42
cypress/integration/trendsSessions.js
Normal file
42
cypress/integration/trendsSessions.js
Normal 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')
|
||||
})
|
||||
})
|
10
frontend/public/icon-white.svg
Normal file
10
frontend/public/icon-white.svg
Normal 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
BIN
frontend/public/squeak.mp3
Normal file
Binary file not shown.
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
22
frontend/src/layout/TopContent/CommandPaletteButton.tsx
Normal file
22
frontend/src/layout/TopContent/CommandPaletteButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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'
|
||||
|
@ -1,4 +1,6 @@
|
||||
.layout-top-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.hide-when-small {
|
||||
display: inline;
|
||||
}
|
40
frontend/src/layout/TopContent/index.tsx
Normal file
40
frontend/src/layout/TopContent/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
32
frontend/src/lib/components/CommandPalette/CommandInput.tsx
Normal file
32
frontend/src/lib/components/CommandPalette/CommandInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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')
|
||||
},
|
||||
}),
|
||||
})
|
107
frontend/src/lib/components/CommandPalette/index.scss
Normal file
107
frontend/src/lib/components/CommandPalette/index.scss
Normal 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%;
|
||||
}
|
52
frontend/src/lib/components/CommandPalette/index.tsx
Normal file
52
frontend/src/lib/components/CommandPalette/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
@ -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 />
|
||||
|
@ -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.`)
|
||||
|
35
frontend/src/lib/hooks/useEventListener.js
Normal file
35
frontend/src/lib/hooks/useEventListener.js
Normal 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
|
||||
)
|
||||
}
|
19
frontend/src/lib/hooks/useOutsideClickHandler.ts
Normal file
19
frontend/src/lib/hooks/useOutsideClickHandler.ts
Normal 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])
|
||||
}
|
@ -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'
|
||||
}
|
||||
|
@ -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)]
|
||||
},
|
||||
],
|
||||
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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')),
|
||||
],
|
||||
}),
|
||||
|
||||
|
@ -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)
|
@ -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)
|
||||
},
|
||||
}),
|
||||
})
|
@ -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 }))
|
@ -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'
|
||||
|
@ -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 }))
|
||||
|
54
frontend/src/scenes/insights/insightCommandLogic.ts
Normal file
54
frontend/src/scenes/insights/insightCommandLogic.ts
Normal 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)
|
||||
},
|
||||
}),
|
||||
})
|
@ -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()}
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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],
|
||||
|
@ -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
|
||||
}
|
||||
|
3
mypy.ini
3
mypy.ini
@ -63,3 +63,6 @@ ignore_missing_imports = True
|
||||
|
||||
[mypy-clickhouse_driver.errors]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-django_filters]
|
||||
ignore_missing_imports = True
|
@ -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": {
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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.",
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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 %}
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
93
yarn.lock
93
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user