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"