diff --git a/cypress/integration/commandPalette.js b/cypress/integration/commandPalette.js
new file mode 100644
index 00000000000..4afcf671d03
--- /dev/null
+++ b/cypress/integration/commandPalette.js
@@ -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')
+ })
+})
diff --git a/cypress/integration/featureFlags.js b/cypress/integration/featureFlags.js
new file mode 100644
index 00000000000..cb7ea09672b
--- /dev/null
+++ b/cypress/integration/featureFlags.js
@@ -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')
+ })
+})
diff --git a/cypress/integration/liveActions.js b/cypress/integration/liveActions.js
new file mode 100644
index 00000000000..1b7ccc44cc4
--- /dev/null
+++ b/cypress/integration/liveActions.js
@@ -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')
+ })
+})
diff --git a/cypress/integration/trendsElements.js b/cypress/integration/trendsElements.js
new file mode 100644
index 00000000000..6bd1a405c1e
--- /dev/null
+++ b/cypress/integration/trendsElements.js
@@ -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')
+ })
+})
diff --git a/cypress/integration/trendsSessions.js b/cypress/integration/trendsSessions.js
new file mode 100644
index 00000000000..2933c8b3099
--- /dev/null
+++ b/cypress/integration/trendsSessions.js
@@ -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')
+ })
+})
diff --git a/frontend/public/icon-white.svg b/frontend/public/icon-white.svg
new file mode 100644
index 00000000000..2e0e169a675
--- /dev/null
+++ b/frontend/public/icon-white.svg
@@ -0,0 +1,10 @@
+
diff --git a/frontend/public/squeak.mp3 b/frontend/public/squeak.mp3
new file mode 100644
index 00000000000..3146d7a0203
Binary files /dev/null and b/frontend/public/squeak.mp3 differ
diff --git a/frontend/src/layout/Sidebar.js b/frontend/src/layout/Sidebar.js
index e6c3506f063..01a3674e991 100644
--- a/frontend/src/layout/Sidebar.js
+++ b/frontend/src/layout/Sidebar.js
@@ -137,7 +137,7 @@ export function Sidebar({ user, sidebarCollapsed, setSidebarCollapsed }) {
title=""
>
- {dashboard.name}
+ {dashboard.name ?? 'Untitled'}
))}
@@ -245,7 +245,7 @@ export function Sidebar({ user, sidebarCollapsed, setSidebarCollapsed }) {
- {featureFlags && featureFlags['billing-management-page'] && (
+ {featureFlags['billing-management-page'] && (
Billing
diff --git a/frontend/src/layout/TopContent.js b/frontend/src/layout/TopContent.js
deleted file mode 100644
index d488a7dfeb8..00000000000
--- a/frontend/src/layout/TopContent.js
+++ /dev/null
@@ -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 (
-
- )
-}
diff --git a/frontend/src/layout/TopContent/CommandPaletteButton.tsx b/frontend/src/layout/TopContent/CommandPaletteButton.tsx
new file mode 100644
index 00000000000..583f2fead3f
--- /dev/null
+++ b/frontend/src/layout/TopContent/CommandPaletteButton.tsx
@@ -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 (
+
+
+ {platformCommandControlKey()} + K
+
+ )
+}
diff --git a/frontend/src/layout/LatestVersion.js b/frontend/src/layout/TopContent/LatestVersion.js
similarity index 100%
rename from frontend/src/layout/LatestVersion.js
rename to frontend/src/layout/TopContent/LatestVersion.js
diff --git a/frontend/src/layout/User.js b/frontend/src/layout/TopContent/User.js
similarity index 100%
rename from frontend/src/layout/User.js
rename to frontend/src/layout/TopContent/User.js
diff --git a/frontend/src/layout/WorkerStats.js b/frontend/src/layout/TopContent/WorkerStats.js
similarity index 98%
rename from frontend/src/layout/WorkerStats.js
rename to frontend/src/layout/TopContent/WorkerStats.js
index 0fc0ee3f305..59c5977f44a 100644
--- a/frontend/src/layout/WorkerStats.js
+++ b/frontend/src/layout/TopContent/WorkerStats.js
@@ -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'
diff --git a/frontend/src/layout/TopContent.scss b/frontend/src/layout/TopContent/index.scss
similarity index 84%
rename from frontend/src/layout/TopContent.scss
rename to frontend/src/layout/TopContent/index.scss
index a2d8fff20dd..bd81c177f39 100644
--- a/frontend/src/layout/TopContent.scss
+++ b/frontend/src/layout/TopContent/index.scss
@@ -1,4 +1,6 @@
.layout-top-content {
+ display: flex;
+ justify-content: space-between;
.hide-when-small {
display: inline;
}
diff --git a/frontend/src/layout/TopContent/index.tsx b/frontend/src/layout/TopContent/index.tsx
new file mode 100644
index 00000000000..8eed621489d
--- /dev/null
+++ b/frontend/src/layout/TopContent/index.tsx
@@ -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 (
+
+
+ {!isMobile() && }
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/lib/components/CommandPalette/CommandInput.tsx b/frontend/src/lib/components/CommandPalette/CommandInput.tsx
new file mode 100644
index 00000000000..d16616826ce
--- /dev/null
+++ b/frontend/src/lib/components/CommandPalette/CommandInput.tsx
@@ -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 (
+
+ {isSqueak ? (
+
+ ) : activeFlow ? (
+
??
+ ) : (
+
+ )}
+
{
+ setInput(event.target.value)
+ }}
+ placeholder={activeFlow?.instruction ?? 'What would you like to do? Try some suggestions…'}
+ />
+
+ )
+}
diff --git a/frontend/src/lib/components/CommandPalette/CommandResults.tsx b/frontend/src/lib/components/CommandPalette/CommandResults.tsx
new file mode 100644
index 00000000000..12c20e67d52
--- /dev/null
+++ b/frontend/src/lib/components/CommandPalette/CommandResults.tsx
@@ -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 (
+ {
+ onMouseEnterResult(result.index)
+ }}
+ onMouseLeave={() => {
+ onMouseLeaveResult()
+ }}
+ onClick={() => {
+ if (isExecutable) executeResult(result)
+ }}
+ >
+
+
{result.display}
+
+ )
+}
+
+interface ResultsGroupProps {
+ scope: string
+ results: CommandResultType[]
+ activeResultIndex: number
+}
+
+export function ResultsGroup({ scope, results, activeResultIndex }: ResultsGroupProps): JSX.Element {
+ return (
+ <>
+ {scope}
+ {results.map((result) => (
+
+ ))}
+ >
+ )
+}
+
+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 (
+
+ {commandSearchResultsGrouped.map(([scope, results]) => (
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/lib/components/CommandPalette/commandPaletteLogic.ts b/frontend/src/lib/components/CommandPalette/commandPaletteLogic.ts
new file mode 100644
index 00000000000..42c28957d73
--- /dev/null
+++ b/frontend/src/lib/components/CommandPalette/commandPaletteLogic.ts
@@ -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
+>({
+ 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 = 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')
+ },
+ }),
+})
diff --git a/frontend/src/lib/components/CommandPalette/index.scss b/frontend/src/lib/components/CommandPalette/index.scss
new file mode 100644
index 00000000000..342d04e01ea
--- /dev/null
+++ b/frontend/src/lib/components/CommandPalette/index.scss
@@ -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%;
+}
diff --git a/frontend/src/lib/components/CommandPalette/index.tsx b/frontend/src/lib/components/CommandPalette/index.tsx
new file mode 100644
index 00000000000..a619be486ca
--- /dev/null
+++ b/frontend/src/lib/components/CommandPalette/index.tsx
@@ -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(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 : (
+
+
+ {(!activeFlow || activeFlow.instruction) && }
+ {!commandSearchResults.length && !activeFlow ? null : }
+
+
+ )
+}
diff --git a/frontend/src/lib/components/CompareFilter/compareFilterLogic.js b/frontend/src/lib/components/CompareFilter/compareFilterLogic.js
index 1e168273fde..55d232dcba2 100644
--- a/frontend/src/lib/components/CompareFilter/compareFilterLogic.js
+++ b/frontend/src/lib/components/CompareFilter/compareFilterLogic.js
@@ -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,
},
],
}),
diff --git a/frontend/src/lib/components/PersonalAPIKeys/PersonalAPIKeys.tsx b/frontend/src/lib/components/PersonalAPIKeys/PersonalAPIKeys.tsx
index 91b3c6d94bd..40facf1eacd 100644
--- a/frontend/src/lib/components/PersonalAPIKeys/PersonalAPIKeys.tsx
+++ b/frontend/src/lib/components/PersonalAPIKeys/PersonalAPIKeys.tsx
@@ -62,7 +62,11 @@ function CreateKeyModal({
}
function RowValue(value: string): JSX.Element {
- return value ? {value} : secret
+ return value ? (
+ {value}
+ ) : (
+ secret
+ )
}
function RowActionsCreator(
@@ -156,7 +160,7 @@ export function PersonalAPIKeys(): JSX.Element {
setIsCreateKeyModalVisible(true)
}}
>
- + Create a Personal API Key
+ + Create Personal API Key
diff --git a/frontend/src/lib/components/PersonalAPIKeys/personalAPIKeysLogic.ts b/frontend/src/lib/components/PersonalAPIKeys/personalAPIKeysLogic.ts
index 697f57c7a90..d6b52acddb8 100644
--- a/frontend/src/lib/components/PersonalAPIKeys/personalAPIKeysLogic.ts
+++ b/frontend/src/lib/components/PersonalAPIKeys/personalAPIKeysLogic.ts
@@ -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>({
loaders: ({ values }) => ({
@@ -24,10 +25,9 @@ export const personalAPIKeysLogic = kea ({
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.`)
diff --git a/frontend/src/lib/hooks/useEventListener.js b/frontend/src/lib/hooks/useEventListener.js
new file mode 100644
index 00000000000..dcbfebaa9e6
--- /dev/null
+++ b/frontend/src/lib/hooks/useEventListener.js
@@ -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
+ )
+}
diff --git a/frontend/src/lib/hooks/useOutsideClickHandler.ts b/frontend/src/lib/hooks/useOutsideClickHandler.ts
new file mode 100644
index 00000000000..d39eb9f37b1
--- /dev/null
+++ b/frontend/src/lib/hooks/useOutsideClickHandler.ts
@@ -0,0 +1,19 @@
+import { useEffect, MutableRefObject } from 'react'
+
+export function useOutsideClickHandler(
+ refOrRefs: MutableRefObject | MutableRefObject[],
+ 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])
+}
diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js
index 74c371384f2..b6d3b0497da 100644
--- a/frontend/src/lib/utils.js
+++ b/frontend/src/lib/utils.js
@@ -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'
+}
diff --git a/frontend/src/models/dashboardsModel.js b/frontend/src/models/dashboardsModel.js
index 5b9252cabac..08f8b731c6a 100644
--- a/frontend/src/models/dashboardsModel.js
+++ b/frontend/src/models/dashboardsModel.js
@@ -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)]
},
],
diff --git a/frontend/src/scenes/App.js b/frontend/src/scenes/App.js
index 11a6090f283..084ad35952a 100644
--- a/frontend/src/scenes/App.js
+++ b/frontend/src/scenes/App.js
@@ -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 (
-
-
-
-
+ <>
+
+
+
-
-
-
- {!user.has_events && image ? (
-
- ) : (
-
- )}
-
-
+
+
+ {!user.has_events && image ? (
+
+ ) : (
+
+ )}
+
+
+
-
+
+ >
)
}
diff --git a/frontend/src/scenes/dashboard/dashboardsLogic.js b/frontend/src/scenes/dashboard/dashboardsLogic.js
index d6d9da3ae10..a9eedcf704f 100644
--- a/frontend/src/scenes/dashboard/dashboardsLogic.js
+++ b/frontend/src/scenes/dashboard/dashboardsLogic.js
@@ -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')),
],
}),
diff --git a/frontend/src/scenes/insights/InsightTabs/FunnelTab.tsx b/frontend/src/scenes/insights/InsightTabs/FunnelTab/FunnelTab.tsx
similarity index 93%
rename from frontend/src/scenes/insights/InsightTabs/FunnelTab.tsx
rename to frontend/src/scenes/insights/InsightTabs/FunnelTab/FunnelTab.tsx
index 4854c8832b3..e78d14bb7ff 100644
--- a/frontend/src/scenes/insights/InsightTabs/FunnelTab.tsx
+++ b/frontend/src/scenes/insights/InsightTabs/FunnelTab/FunnelTab.tsx
@@ -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)
diff --git a/frontend/src/scenes/insights/InsightTabs/FunnelTab/funnelCommandLogic.ts b/frontend/src/scenes/insights/InsightTabs/FunnelTab/funnelCommandLogic.ts
new file mode 100644
index 00000000000..305970a7d40
--- /dev/null
+++ b/frontend/src/scenes/insights/InsightTabs/FunnelTab/funnelCommandLogic.ts
@@ -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
+>({
+ 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)
+ },
+ }),
+})
diff --git a/frontend/src/scenes/insights/InsightTabs/TrendTab.tsx b/frontend/src/scenes/insights/InsightTabs/TrendTab/TrendTab.tsx
similarity index 91%
rename from frontend/src/scenes/insights/InsightTabs/TrendTab.tsx
rename to frontend/src/scenes/insights/InsightTabs/TrendTab/TrendTab.tsx
index eb5ffc71121..b11b50e4210 100644
--- a/frontend/src/scenes/insights/InsightTabs/TrendTab.tsx
+++ b/frontend/src/scenes/insights/InsightTabs/TrendTab/TrendTab.tsx
@@ -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 }))
diff --git a/frontend/src/scenes/insights/InsightTabs/index.ts b/frontend/src/scenes/insights/InsightTabs/index.ts
index 8f33b890482..ca1dc335674 100644
--- a/frontend/src/scenes/insights/InsightTabs/index.ts
+++ b/frontend/src/scenes/insights/InsightTabs/index.ts
@@ -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'
diff --git a/frontend/src/scenes/insights/Insights.js b/frontend/src/scenes/insights/Insights.js
index a149daee3d7..b704acea982 100644
--- a/frontend/src/scenes/insights/Insights.js
+++ b/frontend/src/scenes/insights/Insights.js
@@ -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 }))
diff --git a/frontend/src/scenes/insights/insightCommandLogic.ts b/frontend/src/scenes/insights/insightCommandLogic.ts
new file mode 100644
index 00000000000..240b30c0933
--- /dev/null
+++ b/frontend/src/scenes/insights/insightCommandLogic.ts
@@ -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
+>({
+ 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)
+ },
+ }),
+})
diff --git a/frontend/src/scenes/users/People.js b/frontend/src/scenes/users/People.js
index fc6424dc4bc..7bf3c2c4182 100644
--- a/frontend/src/scenes/users/People.js
+++ b/frontend/src/scenes/users/People.js
@@ -89,7 +89,6 @@ function _People() {
setSearch(e.target.value)}
onKeyDown={(e) => e.keyCode === 13 && fetchPeople()}
diff --git a/frontend/src/scenes/users/Person.js b/frontend/src/scenes/users/Person.js
index e2481d43d83..b869873bfcb 100644
--- a/frontend/src/scenes/users/Person.js
+++ b/frontend/src/scenes/users/Person.js
@@ -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) {
diff --git a/frontend/src/style.scss b/frontend/src/style.scss
index 526bf30cfdf..bc54982ccf7 100644
--- a/frontend/src/style.scss
+++ b/frontend/src/style.scss
@@ -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;
diff --git a/frontend/src/toolbar/actions/actionsLogic.ts b/frontend/src/toolbar/actions/actionsLogic.ts
index 9c249ce1700..3b0f90a576b 100644
--- a/frontend/src/toolbar/actions/actionsLogic.ts
+++ b/frontend/src/toolbar/actions/actionsLogic.ts
@@ -46,7 +46,7 @@ export const actionsLogic = kea>({
(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],
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 03590b81555..db55e2808a7 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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
+}
diff --git a/mypy.ini b/mypy.ini
index b4f3c0f5873..2d9a8049ae8 100644
--- a/mypy.ini
+++ b/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
\ No newline at end of file
diff --git a/package.json b/package.json
index bbb2046fc21..b4ca0adea28 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/posthog/api/person.py b/posthog/api/person.py
index 26b4470910b..3d630be8b58 100644
--- a/posthog/api/person.py
+++ b/posthog/api/person.py
@@ -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)
diff --git a/posthog/api/test/base.py b/posthog/api/test/base.py
index 3ddcf32994a..3dcbb110e76 100644
--- a/posthog/api/test/base.py
+++ b/posthog/api/test/base.py
@@ -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)
diff --git a/posthog/api/test/test_person.py b/posthog/api/test/test_person.py
index 3ac287087fe..f8344bc9528 100644
--- a/posthog/api/test/test_person.py
+++ b/posthog/api/test/test_person.py
@@ -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.",
+ )
diff --git a/posthog/api/test/test_team_user.py b/posthog/api/test/test_team_user.py
index d7f9a4ee1ed..7bcd62b7fc9 100644
--- a/posthog/api/test/test_team_user.py
+++ b/posthog/api/test/test_team_user.py
@@ -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)
diff --git a/posthog/settings.py b/posthog/settings.py
index bd0126c2a07..4b763c71a36 100644
--- a/posthog/settings.py
+++ b/posthog/settings.py
@@ -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,
}
diff --git a/posthog/templates/overlays.html b/posthog/templates/overlays.html
index db08700624c..02b9db8c48e 100644
--- a/posthog/templates/overlays.html
+++ b/posthog/templates/overlays.html
@@ -21,30 +21,4 @@
}
-{% endif %}
-
-{% if debug or request.get_host == 'app.posthog.com' %}
-
-
-{% endif %}
+{% endif %}
\ No newline at end of file
diff --git a/posthog/test/test_person_model.py b/posthog/test/test_person_model.py
index a591ae0bd61..596fcd7bdee 100644
--- a/posthog/test/test_person_model.py
+++ b/posthog/test/test_person_model.py
@@ -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)
diff --git a/requirements.txt b/requirements.txt
index 45aaec295a2..ef991997988 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/webpack.config.js b/webpack.config.js
index 48868f68684..f0262d42b57 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -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: {
diff --git a/yarn.lock b/yarn.lock
index 244e1d98985..46fae3cc8ae 100644
--- a/yarn.lock
+++ b/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"