diff --git a/frontend/__snapshots__/components-command-bar--actions--dark.png b/frontend/__snapshots__/components-command-bar--actions--dark.png new file mode 100644 index 0000000000000..3d45834389514 Binary files /dev/null and b/frontend/__snapshots__/components-command-bar--actions--dark.png differ diff --git a/frontend/__snapshots__/components-command-bar--actions--light.png b/frontend/__snapshots__/components-command-bar--actions--light.png new file mode 100644 index 0000000000000..c7ee7dc2c235f Binary files /dev/null and b/frontend/__snapshots__/components-command-bar--actions--light.png differ diff --git a/frontend/__snapshots__/components-command-bar--search--dark.png b/frontend/__snapshots__/components-command-bar--search--dark.png new file mode 100644 index 0000000000000..af6d59ab69347 Binary files /dev/null and b/frontend/__snapshots__/components-command-bar--search--dark.png differ diff --git a/frontend/__snapshots__/components-command-bar--search--light.png b/frontend/__snapshots__/components-command-bar--search--light.png new file mode 100644 index 0000000000000..9cf309b9e9386 Binary files /dev/null and b/frontend/__snapshots__/components-command-bar--search--light.png differ diff --git a/frontend/__snapshots__/components-command-bar--search.png b/frontend/__snapshots__/components-command-bar--search.png index d155d92fac149..16a258d97ca00 100644 Binary files a/frontend/__snapshots__/components-command-bar--search.png and b/frontend/__snapshots__/components-command-bar--search.png differ diff --git a/frontend/__snapshots__/components-command-bar--shortcuts--dark.png b/frontend/__snapshots__/components-command-bar--shortcuts--dark.png new file mode 100644 index 0000000000000..fec057699fba5 Binary files /dev/null and b/frontend/__snapshots__/components-command-bar--shortcuts--dark.png differ diff --git a/frontend/__snapshots__/components-command-bar--shortcuts--light.png b/frontend/__snapshots__/components-command-bar--shortcuts--light.png new file mode 100644 index 0000000000000..bb844456a7e6d Binary files /dev/null and b/frontend/__snapshots__/components-command-bar--shortcuts--light.png differ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e3ce2cb92e6c..2f88e8429b99a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -52,6 +52,8 @@ import { RoleMemberType, RolesListParams, RoleType, + SearchListParams, + SearchResponse, SessionRecordingPlaylistType, SessionRecordingSnapshotResponse, SessionRecordingsResponse, @@ -461,6 +463,11 @@ class ApiRequest { return this.persons().addPathComponent('activity') } + // # Search + public search(teamId?: TeamType['id']): ApiRequest { + return this.projectsDetail(teamId).addPathComponent('search') + } + // # Annotations public annotations(teamId?: TeamType['id']): ApiRequest { return this.projectsDetail(teamId).addPathComponent('annotations') @@ -1226,6 +1233,12 @@ const api = { }, }, + search: { + async list(params: SearchListParams): Promise { + return await new ApiRequest().search().withQueryString(toParams(params, true)).get() + }, + }, + sharing: { async get({ dashboardId, diff --git a/frontend/src/lib/components/CommandBar/CommandBar.stories.tsx b/frontend/src/lib/components/CommandBar/CommandBar.stories.tsx index 898e3bc1de0c4..67fc3cb657868 100644 --- a/frontend/src/lib/components/CommandBar/CommandBar.stories.tsx +++ b/frontend/src/lib/components/CommandBar/CommandBar.stories.tsx @@ -12,37 +12,128 @@ const SEARCH_RESULT = { results: [ { type: 'insight', - result_id: '3b7NrJXF', + result_id: 'NmLsyopa', extra_fields: { name: '', + query: null, + filters: { + events: [ + { + id: '$pageview', + math: 'total', + name: '$pageview', + type: 'events', + order: 0, + }, + ], + display: 'ActionsLineGraph', + insight: 'TRENDS', + interval: 'day', + entity_type: 'events', + filter_test_accounts: true, + }, description: '', - derived_name: 'SQL query', }, }, { type: 'insight', - result_id: 'U2W7bAq1', + result_id: 'QcCPEk7d', + extra_fields: { + name: 'Daily unique visitors over time', + query: null, + filters: { + events: [ + { + id: '$pageview', + math: 'dau', + type: 'events', + order: 0, + }, + { + id: null, + math: 'total', + type: 'events', + order: 1, + }, + ], + date_to: null, + display: 'ActionsLineGraph', + insight: 'TRENDS', + interval: 'day', + date_from: '-6m', + entity_type: 'events', + }, + description: null, + }, + }, + { + type: 'insight', + result_id: '38EAleI9', extra_fields: { name: '', + query: { + full: true, + kind: 'DataTableNode', + source: { + kind: 'HogQLQuery', + query: ' select event,\n person.properties.email,\n properties.$browser,\n count()\n from events\n where {filters} -- replaced with global date and property filters\n and person.properties.email is not null\n group by event,\n properties.$browser,\n person.properties.email\n order by count() desc\n limit 100', + filters: { + dateRange: { + date_from: '-24h', + }, + }, + }, + }, + filters: {}, description: '', - derived_name: 'All events → All events user conversion rate', }, }, { - type: 'feature_flag', - result_id: '120', + type: 'insight', + result_id: 'zi5MCnjs', extra_fields: { - key: 'person-on-events-enabled', - name: 'person-on-events-enabled', + name: 'Feature Flag Called Total Volume', + query: null, + filters: { + events: [ + { + id: '$feature_flag_called', + name: '$feature_flag_called', + type: 'events', + }, + ], + display: 'ActionsLineGraph', + insight: 'TRENDS', + interval: 'day', + breakdown: '$feature_flag_response', + date_from: '-30d', + properties: { + type: 'AND', + values: [ + { + type: 'AND', + values: [ + { + key: '$feature_flag', + type: 'event', + value: 'notebooks', + }, + ], + }, + ], + }, + breakdown_type: 'event', + filter_test_accounts: false, + }, + description: 'Shows the number of total calls made on feature flag with key: notebooks', }, }, { - type: 'insight', - result_id: '44fpCyF7', + type: 'feature_flag', + result_id: '120', extra_fields: { - name: '', - description: '', - derived_name: 'User lifecycle based on Pageview', + key: 'person-on-events-enabled', + name: 'person-on-events-enabled', }, }, { @@ -61,44 +152,6 @@ const SEARCH_RESULT = { text_content: 'Notes 27/09\nasd\nas\nda\ns\nd\nlalala', }, }, - { - type: 'insight', - result_id: 'Ap5YYl2H', - extra_fields: { - name: '', - description: '', - derived_name: - 'Pageview count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count', - }, - }, - { - type: 'insight', - result_id: '4Xaltnro', - extra_fields: { - name: '', - description: '', - derived_name: 'User paths based on page views and custom events', - }, - }, - { - type: 'insight', - result_id: 'HUkkq7Au', - extra_fields: { - name: '', - description: '', - derived_name: - 'Pageview count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count & All events count', - }, - }, - { - type: 'insight', - result_id: 'hF5z02Iw', - extra_fields: { - name: '', - description: '', - derived_name: 'Pageview count & All events count', - }, - }, { type: 'feature_flag', result_id: '143', @@ -115,6 +168,28 @@ const SEARCH_RESULT = { name: 'onboarding-v2-demo', }, }, + { + type: 'insight', + result_id: 'miwdcAAu', + extra_fields: { + name: '', + query: { + full: true, + kind: 'DataTableNode', + source: { + kind: 'HogQLQuery', + query: ' select event,\n person.properties.email,\n properties.$browser,\n count()\n from events\n where {filters} -- replaced with global date and property filters\n and person.properties.email is not null\n group by event,\n properties.$browser,\n person.properties.email\n order by count() desc\n limit 100', + filters: { + dateRange: { + date_from: '-24h', + }, + }, + }, + }, + filters: {}, + description: '', + }, + }, { type: 'feature_flag', result_id: '142', @@ -124,12 +199,11 @@ const SEARCH_RESULT = { }, }, { - type: 'insight', - result_id: '94r9bOyB', + type: 'notebook', + result_id: 'eq4n8PQY', extra_fields: { - name: '', - description: '', - derived_name: 'Pageview count & All events count', + title: 'asd', + text_content: 'asd', }, }, { @@ -140,21 +214,61 @@ const SEARCH_RESULT = { description: 'Company overview.', }, }, - { - type: 'notebook', - result_id: 'eq4n8PQY', - extra_fields: { - title: 'asd', - text_content: 'asd', - }, - }, { type: 'insight', - result_id: 'QcCPEk7d', + result_id: 'YZjAFWBU', extra_fields: { - name: 'Daily unique visitors over time', + name: 'Homepage view to signup conversion', + query: null, + filters: { + events: [ + { + id: '$pageview', + name: '$pageview', + type: 'events', + order: 0, + properties: [ + { + key: '$current_url', + type: 'event', + value: 'https://hedgebox.net/', + operator: 'exact', + }, + ], + custom_name: 'Viewed homepage', + }, + { + id: '$pageview', + name: '$pageview', + type: 'events', + order: 1, + properties: [ + { + key: '$current_url', + type: 'event', + value: 'https://hedgebox.net/signup/', + operator: 'regex', + }, + ], + custom_name: 'Viewed signup page', + }, + { + id: 'signed_up', + name: 'signed_up', + type: 'events', + order: 2, + custom_name: 'Signed up', + }, + ], + actions: [], + display: 'FunnelViz', + insight: 'FUNNELS', + interval: 'day', + date_from: '-1m', + funnel_viz_type: 'steps', + filter_test_accounts: true, + }, description: null, - derived_name: '$pageview unique users & All events count', }, }, { @@ -167,21 +281,50 @@ const SEARCH_RESULT = { }, { type: 'insight', - result_id: 'PWwez0ma', - extra_fields: { - name: 'Most popular pages', - description: null, - derived_name: null, - }, - }, - { - type: 'insight', - result_id: 'HKTERZ40', + result_id: 'xK9vs4D2', extra_fields: { - name: 'Feature Flag calls made by unique users per variant', - description: - 'Shows the number of unique user calls made on feature flag per variant with key: notebooks', - derived_name: null, + name: '', + query: null, + filters: { + events: [ + { + id: '$pageview', + math: 'total', + name: '$pageview', + type: 'events', + order: 0, + }, + { + id: null, + math: 'total', + type: 'events', + order: 1, + }, + { + id: null, + math: 'total', + type: 'events', + order: 2, + }, + ], + insight: 'FUNNELS', + interval: 'day', + date_from: '-7d', + exclusions: [ + { + id: '$pageview', + name: '$pageview', + type: 'events', + uuid: '40e0f8a9-1297-41e4-b7ca-54d00a9c1d82', + order: 0, + funnel_to_step: 1, + funnel_from_step: 0, + }, + ], + entity_type: 'events', + funnel_viz_type: 'steps', + }, + description: '', }, }, { @@ -202,11 +345,26 @@ const SEARCH_RESULT = { }, { type: 'insight', - result_id: 'uE7xieYc', + result_id: 'vLbs5bhA', extra_fields: { name: '', + query: null, + filters: { + events: [ + { + id: '$pageview', + math: 'total', + name: '$pageview', + type: 'events', + order: 0, + }, + ], + insight: 'LIFECYCLE', + shown_as: 'Lifecycle', + entity_type: 'events', + filter_test_accounts: false, + }, description: '', - derived_name: 'Pageview count', }, }, { @@ -217,22 +375,117 @@ const SEARCH_RESULT = { name: 'surveys-multiple-questions', }, }, + { + type: 'action', + result_id: '3', + extra_fields: { + name: 'typed into search', + description: '', + }, + }, { type: 'insight', - result_id: 'AVPsaax4', + result_id: 'QYrl34sX', extra_fields: { - name: 'Monthly app revenue', - description: null, - derived_name: null, + name: 'Feature Flag calls made by unique users per variant', + query: null, + filters: { + events: [ + { + id: '$feature_flag_called', + math: 'dau', + name: '$feature_flag_called', + type: 'events', + }, + ], + display: 'ActionsTable', + insight: 'TRENDS', + interval: 'day', + breakdown: '$feature_flag_response', + date_from: '-30d', + properties: { + type: 'AND', + values: [ + { + type: 'AND', + values: [ + { + key: '$feature_flag', + type: 'event', + value: 'cmd-k-search', + }, + ], + }, + ], + }, + breakdown_type: 'event', + filter_test_accounts: false, + }, + description: + 'Shows the number of unique user calls made on feature flag per variant with key: cmd-k-search', + }, + }, + { + type: 'insight', + result_id: '4Xaltnro', + extra_fields: { + name: '', + query: null, + filters: { + insight: 'PATHS', + step_limit: 5, + entity_type: 'events', + funnel_paths: 'funnel_path_before_step', + funnel_filter: { + events: [ + { + id: '$pageview', + math: 'total', + name: '$pageview', + type: 'events', + order: 0, + }, + { + id: null, + math: 'total', + type: 'events', + order: 1, + }, + ], + insight: 'FUNNELS', + exclusions: [], + funnel_step: 2, + funnel_viz_type: 'steps', + filter_test_accounts: true, + }, + include_event_types: ['$pageview', 'custom_event'], + }, + description: '', + }, + }, + { + type: 'feature_flag', + result_id: '148', + extra_fields: { + key: 'show-product-intro-existing-products', + name: 'show-product-intro-existing-products', + }, + }, + { + type: 'feature_flag', + result_id: '4', + extra_fields: { + key: 'notebooks', + name: '', }, }, ], counts: { - insight: 80, + insight: 89, dashboard: 14, experiment: 1, feature_flag: 66, - notebook: 2, + notebook: 5, action: 4, cohort: 3, }, @@ -252,6 +505,7 @@ const meta: Meta = { layout: 'fullscreen', testOptions: { snapshotTargetSelector: '[data-attr="command-bar"]', + include3000: true, }, viewMode: 'story', }, diff --git a/frontend/src/lib/components/CommandBar/SearchBarTab.tsx b/frontend/src/lib/components/CommandBar/SearchBarTab.tsx index e71cda427e5bd..d23504286798d 100644 --- a/frontend/src/lib/components/CommandBar/SearchBarTab.tsx +++ b/frontend/src/lib/components/CommandBar/SearchBarTab.tsx @@ -2,50 +2,52 @@ import { useActions, useValues } from 'kea' import { Spinner } from 'lib/lemon-ui/Spinner' import { RefObject } from 'react' -import { resultTypeToName } from './constants' +import { Tab, tabToName } from './constants' import { searchBarLogic } from './searchBarLogic' -import { ResultTypeWithAll } from './types' type SearchBarTabProps = { - type: ResultTypeWithAll - active: boolean - count?: number | null + tab: Tab inputRef: RefObject } -export const SearchBarTab = ({ type, active, count, inputRef }: SearchBarTabProps): JSX.Element => { +export const SearchBarTab = ({ tab, inputRef }: SearchBarTabProps): JSX.Element => { + const { activeTab } = useValues(searchBarLogic) const { setActiveTab } = useActions(searchBarLogic) + + const isActive = tab === activeTab + return (
{ - setActiveTab(type) + setActiveTab(tab) inputRef.current?.focus() }} > - {resultTypeToName[type]} - + {tabToName[tab]} +
) } type CountProps = { - type: ResultTypeWithAll - active: boolean - count?: number | null + tab: Tab } -const Count = ({ type, active, count }: CountProps): JSX.Element | null => { - const { searchResponseLoading } = useValues(searchBarLogic) +const Count = ({ tab }: CountProps): JSX.Element | null => { + const { activeTab, tabsCount, tabsLoading } = useValues(searchBarLogic) + + // TODO: replace todo with condition that time since search start > 1s + const isActive = tab === activeTab || true - if (type === 'all') { + if (tab === Tab.All) { return null - } else if (active && searchResponseLoading) { + } else if (isActive && tabsLoading.includes(tab)) { return - } else if (count != null) { - return {count} + } else if (tabsCount[tab] != null) { + return {tabsCount[tab]} } else { return } diff --git a/frontend/src/lib/components/CommandBar/SearchResult.tsx b/frontend/src/lib/components/CommandBar/SearchResult.tsx index 2c7d3d091358c..945720851378d 100644 --- a/frontend/src/lib/components/CommandBar/SearchResult.tsx +++ b/frontend/src/lib/components/CommandBar/SearchResult.tsx @@ -11,12 +11,12 @@ import { groupsModel } from '~/models/groupsModel' import { Node } from '~/queries/schema' import { FilterType } from '~/types' -import { resultTypeToName } from './constants' +import { tabToName } from './constants' import { searchBarLogic, urlForResult } from './searchBarLogic' -import { SearchResult as SearchResultType } from './types' +import { SearchResult as ResultType } from './types' type SearchResultProps = { - result: SearchResultType + result: ResultType resultIndex: number focused: boolean keyboardFocused: boolean @@ -69,7 +69,7 @@ export const SearchResult = ({ result, resultIndex, focused, keyboardFocused }: ref={ref} >
- {resultTypeToName[result.type]} + {tabToName[result.type]} @@ -91,7 +91,7 @@ export const SearchResultSkeleton = (): JSX.Element => ( ) type ResultNameProps = { - result: SearchResultType + result: ResultType } export const ResultName = ({ result }: ResultNameProps): JSX.Element | null => { @@ -139,6 +139,6 @@ export const ResultDescription = ({ result }: ResultNameProps): JSX.Element | nu /> ) } else { - return extra_fields.description ? {extra_fields.description} : No description. + return 'description' in extra_fields ? {extra_fields.description} : No description. } } diff --git a/frontend/src/lib/components/CommandBar/SearchResultPreview.tsx b/frontend/src/lib/components/CommandBar/SearchResultPreview.tsx index 57bd498e353ea..e79de19baaa3b 100644 --- a/frontend/src/lib/components/CommandBar/SearchResultPreview.tsx +++ b/frontend/src/lib/components/CommandBar/SearchResultPreview.tsx @@ -1,21 +1,21 @@ import { useValues } from 'kea' import { ResultDescription, ResultName } from 'lib/components/CommandBar/SearchResult' -import { resultTypeToName } from './constants' +import { tabToName } from './constants' import { searchBarLogic } from './searchBarLogic' export const SearchResultPreview = (): JSX.Element | null => { - const { activeResultIndex, filterSearchResults } = useValues(searchBarLogic) + const { activeResultIndex, combinedSearchResults } = useValues(searchBarLogic) - if (!filterSearchResults || filterSearchResults.length === 0) { + if (!combinedSearchResults || combinedSearchResults.length === 0) { return null } - const result = filterSearchResults[activeResultIndex] + const result = combinedSearchResults[activeResultIndex] return (
-
{resultTypeToName[result.type]}
+
{tabToName[result.type]}
diff --git a/frontend/src/lib/components/CommandBar/SearchResults.tsx b/frontend/src/lib/components/CommandBar/SearchResults.tsx index 0217f5dc1c6e2..b77ed70363c79 100644 --- a/frontend/src/lib/components/CommandBar/SearchResults.tsx +++ b/frontend/src/lib/components/CommandBar/SearchResults.tsx @@ -6,28 +6,28 @@ import { SearchResult, SearchResultSkeleton } from './SearchResult' import { SearchResultPreview } from './SearchResultPreview' export const SearchResults = (): JSX.Element => { - const { filterSearchResults, searchResponseLoading, activeResultIndex, keyboardResultIndex } = + const { combinedSearchResults, combinedSearchLoading, activeResultIndex, keyboardResultIndex } = useValues(searchBarLogic) return (
- {searchResponseLoading && ( + {combinedSearchLoading && ( <> )} - {!searchResponseLoading && filterSearchResults?.length === 0 && ( + {!combinedSearchLoading && combinedSearchResults?.length === 0 && (

No results

This doesn't happen often, but we're stumped!

)} - {!searchResponseLoading && - filterSearchResults?.map((result, index) => ( + {!combinedSearchLoading && + combinedSearchResults?.map((result, index) => ( } -export const SearchTabs = ({ inputRef }: SearchTabsProps): JSX.Element | null => { - const { searchResponse, activeTab } = useValues(searchBarLogic) - - if (!searchResponse) { - return null - } - - return ( -
- - {Object.entries(searchResponse.counts).map(([type, count]) => ( - - ))} -
- ) -} +export const SearchTabs = ({ inputRef }: SearchTabsProps): JSX.Element | null => ( +
+ {Object.values(Tab).map((tab) => ( + + ))} +
+) diff --git a/frontend/src/lib/components/CommandBar/commandBarLogic.ts b/frontend/src/lib/components/CommandBar/commandBarLogic.ts index ba1d9f30ba955..41da175927c2f 100644 --- a/frontend/src/lib/components/CommandBar/commandBarLogic.ts +++ b/frontend/src/lib/components/CommandBar/commandBarLogic.ts @@ -20,13 +20,9 @@ export const commandBarLogic = kea([ setCommandBar: (_, { status }) => status, hideCommandBar: () => BarStatus.HIDDEN, toggleSearchBar: (previousState) => - [BarStatus.HIDDEN, BarStatus.SHOW_SHORTCUTS].includes(previousState) - ? BarStatus.SHOW_SEARCH - : BarStatus.HIDDEN, + previousState === BarStatus.SHOW_SEARCH ? BarStatus.HIDDEN : BarStatus.SHOW_SEARCH, toggleActionsBar: (previousState) => - [BarStatus.HIDDEN, BarStatus.SHOW_SHORTCUTS].includes(previousState) - ? BarStatus.SHOW_ACTIONS - : BarStatus.HIDDEN, + previousState === BarStatus.SHOW_ACTIONS ? BarStatus.HIDDEN : BarStatus.SHOW_ACTIONS, toggleShortcutOverview: (previousState) => previousState === BarStatus.HIDDEN ? BarStatus.SHOW_SHORTCUTS : previousState, }, diff --git a/frontend/src/lib/components/CommandBar/constants.ts b/frontend/src/lib/components/CommandBar/constants.ts index 14396bb019f20..136f066123ac6 100644 --- a/frontend/src/lib/components/CommandBar/constants.ts +++ b/frontend/src/lib/components/CommandBar/constants.ts @@ -1,17 +1,28 @@ -import { ResultTypeWithAll } from './types' - -export const resultTypeToName: Record = { - all: 'All', - action: 'Actions', - cohort: 'Cohorts', - dashboard: 'Dashboards', - experiment: 'Experiments', - feature_flag: 'Feature flags', - insight: 'Insights', - notebook: 'Notebooks', -} - export const actionScopeToName: Record = { global: 'Global', insights: 'Insights', } + +export enum Tab { + All = 'all', + Action = 'action', + Cohort = 'cohort', + Dashboard = 'dashboard', + Experiment = 'experiment', + FeatureFlag = 'feature_flag', + Insight = 'insight', + Notebook = 'notebook', + Person = 'person', +} + +export const tabToName: Record = { + [Tab.All]: 'All', + [Tab.Action]: 'Actions', + [Tab.Cohort]: 'Cohorts', + [Tab.Dashboard]: 'Dashboards', + [Tab.Experiment]: 'Experiments', + [Tab.FeatureFlag]: 'Feature flags', + [Tab.Insight]: 'Insights', + [Tab.Notebook]: 'Notebooks', + [Tab.Person]: 'Persons', +} diff --git a/frontend/src/lib/components/CommandBar/searchBarLogic.ts b/frontend/src/lib/components/CommandBar/searchBarLogic.ts index 2f3b04715c598..0890298f99f27 100644 --- a/frontend/src/lib/components/CommandBar/searchBarLogic.ts +++ b/frontend/src/lib/components/CommandBar/searchBarLogic.ts @@ -1,14 +1,30 @@ import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { router } from 'kea-router' -import api from 'lib/api' +import api, { CountedPaginatedResponse } from 'lib/api' import { urls } from 'scenes/urls' -import { InsightShortId } from '~/types' +import { InsightShortId, PersonType, SearchableEntity, SearchResponse } from '~/types' import { commandBarLogic } from './commandBarLogic' +import { Tab } from './constants' import type { searchBarLogicType } from './searchBarLogicType' -import { BarStatus, ResultTypeWithAll, SearchResponse, SearchResult } from './types' +import { BarStatus, PersonResult, SearchResult } from './types' + +const DEBOUNCE_MS = 300 + +function rankPersons(persons: PersonType[], query: string): PersonResult[] { + // We know each person matches the query. To rank them + // between the other results, we rank them higher, when the + // query is longer. + const personsRank = query.length / (query.length + 2.0) + return persons.map((person) => ({ + type: 'person', + result_id: person.distinct_ids[0], + extra_fields: { ...person }, + rank: personsRank, + })) +} export const searchBarLogic = kea([ path(['lib', 'components', 'CommandBar', 'searchBarLogic']), @@ -16,8 +32,9 @@ export const searchBarLogic = kea([ actions: [commandBarLogic, ['hideCommandBar', 'setCommandBar']], }), actions({ + search: true, setSearchQuery: (query: string) => ({ query }), - setActiveTab: (tab: ResultTypeWithAll) => ({ tab }), + setActiveTab: (tab: Tab) => ({ tab }), onArrowUp: (activeIndex: number, maxIndex: number) => ({ activeIndex, maxIndex }), onArrowDown: (activeIndex: number, maxIndex: number) => ({ activeIndex, maxIndex }), onMouseEnterResult: (index: number) => ({ index }), @@ -26,24 +43,48 @@ export const searchBarLogic = kea([ openResult: (index: number) => ({ index }), }), loaders(({ values }) => ({ - searchResponse: [ + rawSearchResponse: [ null as SearchResponse | null, { loadSearchResponse: async (_, breakpoint) => { - await breakpoint(300) - if (values.activeTab === 'all') { - return await api.get(`api/projects/@current/search?q=${values.searchQuery}`) + await breakpoint(DEBOUNCE_MS) + + if (values.activeTab === Tab.All) { + return await api.search.list({ q: values.searchQuery }) } else { - return await api.get( - `api/projects/@current/search?q=${values.searchQuery}&entities=${values.activeTab}` - ) + return await api.search.list({ + q: values.searchQuery, + entities: [values.activeTab.toLowerCase() as SearchableEntity], + }) } }, }, ], + rawPersonsResponse: [ + null as CountedPaginatedResponse | null, + { + loadPersonsResponse: async (_, breakpoint) => { + await breakpoint(DEBOUNCE_MS) + + return await api.persons.list({ search: values.searchQuery }) + }, + }, + ], })), reducers({ searchQuery: ['', { setSearchQuery: (_, { query }) => query }], + rawSearchResponse: [ + null as SearchResponse | null, + { + search: () => null, + }, + ], + rawPersonsResponse: [ + null as CountedPaginatedResponse | null, + { + search: () => null, + }, + ], keyboardResultIndex: [ 0, { @@ -66,7 +107,7 @@ export const searchBarLogic = kea([ }, ], activeTab: [ - 'all' as ResultTypeWithAll, + Tab.All as Tab, { setActiveTab: (_, { tab }) => tab, }, @@ -74,35 +115,85 @@ export const searchBarLogic = kea([ isAutoScrolling: [false, { setIsAutoScrolling: (_, { scrolling }) => scrolling }], }), selectors({ - searchResults: [(s) => [s.searchResponse], (searchResponse) => searchResponse?.results], - searchCounts: [(s) => [s.searchResponse], (searchResponse) => searchResponse?.counts], - filterSearchResults: [ - (s) => [s.searchResults, s.activeTab], - (searchResults, activeTab) => { - if (activeTab === 'all') { - return searchResults + combinedSearchResults: [ + (s) => [s.rawSearchResponse, s.rawPersonsResponse, s.searchQuery], + (searchResponse, personsResponse, query) => { + if (!searchResponse && !personsResponse) { + return null + } + + return [ + ...(searchResponse ? searchResponse.results : []), + ...(personsResponse ? rankPersons(personsResponse.results, query) : []), + ].sort((a, b) => (a.rank && b.rank ? a.rank - b.rank : 1)) + }, + ], + combinedSearchLoading: [ + (s) => [s.rawSearchResponseLoading, s.rawPersonsResponseLoading], + (searchLoading, personsLoading) => searchLoading && personsLoading, + ], + tabsCount: [ + (s) => [s.rawSearchResponse, s.rawPersonsResponse], + (searchResponse, personsResponse): Record => { + const counts = {} + const personsResults = personsResponse?.results + + Object.values(Tab).forEach((tab) => { + counts[tab] = searchResponse?.counts[tab]?.toString() || null + }) + + if (personsResults !== undefined) { + counts[Tab.Person] = personsResults.length === 100 ? '>=100' : personsResults.length.toString() } - return searchResults?.filter((r) => r.type === activeTab) + + return counts as Record }, ], - maxIndex: [(s) => [s.filterSearchResults], (searchResults) => (searchResults ? searchResults.length - 1 : 0)], + tabsLoading: [ + (s) => [s.rawSearchResponseLoading, s.rawPersonsResponseLoading, s.activeTab], + (searchLoading, personsLoading, activeTab): Tab[] => { + const tabs: Tab[] = [] + + if (searchLoading) { + if (activeTab === Tab.All) { + tabs.push(...Object.values(Tab).filter((tab) => ![Tab.All, Tab.Person].includes(tab))) + } else { + tabs.push(activeTab) + } + } + + if (personsLoading) { + tabs.push(Tab.Person) + } + + return tabs + }, + ], + maxIndex: [ + (s) => [s.combinedSearchResults], + (combinedResults) => (combinedResults ? combinedResults.length - 1 : 0), + ], activeResultIndex: [ (s) => [s.keyboardResultIndex, s.hoverResultIndex], (keyboardResultIndex: number, hoverResultIndex: number | null) => hoverResultIndex || keyboardResultIndex, ], - tabs: [ - (s) => [s.searchCounts], - (counts): ResultTypeWithAll[] => ['all', ...(Object.keys(counts || {}) as ResultTypeWithAll[])], - ], }), listeners(({ values, actions }) => ({ + setSearchQuery: actions.search, + setActiveTab: actions.search, + search: (_) => { + if (values.activeTab === Tab.All || values.activeTab !== Tab.Person) { + actions.loadSearchResponse(_) + } + if (values.activeTab === Tab.All || values.activeTab === Tab.Person) { + actions.loadPersonsResponse(_) + } + }, openResult: ({ index }) => { - const result = values.searchResults![index] + const result = values.combinedSearchResults![index] router.actions.push(urlForResult(result)) actions.hideCommandBar() }, - setSearchQuery: actions.loadSearchResponse, - setActiveTab: actions.loadSearchResponse, })), afterMount(({ actions, values, cache }) => { // load initial results @@ -139,13 +230,14 @@ export const searchBarLogic = kea([ } } else if (event.key === 'Tab') { event.preventDefault() - const currentIndex = values.tabs.findIndex((tab) => tab === values.activeTab) + const tabs = Object.values(Tab) + const currentIndex = tabs.findIndex((tab) => tab === values.activeTab) if (event.shiftKey) { - const prevIndex = currentIndex === 0 ? values.tabs.length - 1 : currentIndex - 1 - actions.setActiveTab(values.tabs[prevIndex]) + const prevIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1 + actions.setActiveTab(tabs[prevIndex]) } else { - const nextIndex = currentIndex === values.tabs.length - 1 ? 0 : currentIndex + 1 - actions.setActiveTab(values.tabs[nextIndex]) + const nextIndex = currentIndex === tabs.length - 1 ? 0 : currentIndex + 1 + actions.setActiveTab(tabs[nextIndex]) } } } @@ -173,7 +265,10 @@ export const urlForResult = (result: SearchResult): string => { return urls.insightView(result.result_id as InsightShortId) case 'notebook': return urls.notebook(result.result_id) + case 'person': + return urls.personByDistinctId(result.result_id) default: - throw new Error(`No action for type '${result.type}' defined.`) + // @ts-expect-error + throw new Error(`No action for type '${result?.type}' defined.`) } } diff --git a/frontend/src/lib/components/CommandBar/types.ts b/frontend/src/lib/components/CommandBar/types.ts index 3a6a482c26453..5b6d18733be61 100644 --- a/frontend/src/lib/components/CommandBar/types.ts +++ b/frontend/src/lib/components/CommandBar/types.ts @@ -1,3 +1,5 @@ +import { PersonType, SearchableEntity, SearchResultType } from '~/types' + export enum BarStatus { HIDDEN = 'hidden', SHOW_SEARCH = 'show_search', @@ -5,18 +7,13 @@ export enum BarStatus { SHOW_SHORTCUTS = 'show_shortcuts', } -export type ResultType = 'action' | 'cohort' | 'insight' | 'dashboard' | 'experiment' | 'feature_flag' | 'notebook' - -export type ResultTypeWithAll = ResultType | 'all' +export type ResultType = SearchableEntity | 'person' -export type SearchResult = { +export type PersonResult = { + type: 'person' result_id: string - type: ResultType - name: string | null - extra_fields: Record + extra_fields: PersonType + rank: number } -export type SearchResponse = { - results: SearchResult[] - counts: Record -} +export type SearchResult = SearchResultType | PersonResult diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c7ef128be9fdf..03390eb21c0ea 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -831,6 +831,29 @@ export interface PersonListParams { include_total?: boolean // PostHog 3000-only } +export type SearchableEntity = + | 'action' + | 'cohort' + | 'insight' + | 'dashboard' + | 'experiment' + | 'feature_flag' + | 'notebook' + +export type SearchListParams = { q: string; entities?: SearchableEntity[] } + +export type SearchResultType = { + result_id: string + type: SearchableEntity + rank: number | null + extra_fields: Record +} + +export type SearchResponse = { + results: SearchResultType[] + counts: Record +} + export interface MatchedRecordingEvent { uuid: string } diff --git a/posthog/api/search.py b/posthog/api/search.py index 54bae3609ab63..e01298fdccf6b 100644 --- a/posthog/api/search.py +++ b/posthog/api/search.py @@ -156,6 +156,7 @@ def class_queryset( ) qs = qs.filter(rank__gt=0.05) values.append("rank") + qs.annotate(rank=F("rank")) # specify fields to fetch qs = qs.values(*values)