Skip to content

Commit

Permalink
feat(3000): help & support panel (#20710)
Browse files Browse the repository at this point in the history
* html for new support pane

* add algolia search component

* polish

* docs are not old anymore

* ux improvements

* add filters

* reset active option

* styles

* email an engineer button

* fix panel title

* add checkmark to resolved questions

* add result count to tags / allow tabbing through tags

* polish

* docs links

* tooltips

* inline support form

* remove unnecessary effect

* use correct color in tooltip

* Revert "tooltips"

This reverts commit 5603a65.

* hook it up

* fix lockfile

* use iconinfo

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (2)

* reup lockfile

* Update UI snapshots for `chromium` (1)

* fix

* use std colors (except for purple)

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (2)

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (1)

* Update unit.json

* Update docker-compose.dev-full.yml

* Update docker-compose.dev-full.yml

* upgrade @babel/runtime

* move to a regular dep?

* address pr feedback

* add stories

* handle arrowLeft and ArrowRight keydown

* Update UI snapshots for `chromium` (1)

---------

Co-authored-by: Eli Kinsey <[email protected]>
Co-authored-by: Raquel Smith <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
4 people authored Mar 18, 2024
1 parent 7435fe6 commit c8545b9
Show file tree
Hide file tree
Showing 29 changed files with 956 additions and 151 deletions.
4 changes: 0 additions & 4 deletions docker-compose.base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ services:
KAFKA_HOSTS: 'kafka:9092'
REDIS_URL: 'redis://redis:6379/'


plugins:
command: ./bin/plugin-server --no-restart-loop
restart: on-failure
Expand Down Expand Up @@ -152,8 +151,6 @@ services:
volumes:
- /var/lib/elasticsearch/data
temporal:
environment:
- DB=postgresql
- DB_PORT=5432
Expand Down Expand Up @@ -190,4 +187,3 @@ services:
restart: on-failure
environment:
TEMPORAL_HOST: temporal
2 changes: 1 addition & 1 deletion docker-compose.dev-full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,4 @@ services:
- clickhouse
- kafka
- object_storage
- temporal
- temporal
Binary file modified frontend/__snapshots__/lemon-ui-colors--color-palette--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/lemon-ui-colors--color-palette--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/lemon-ui-lemon-slider--basic--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/lemon-ui-lemon-slider--basic--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/lemon-ui-lemon-tag--breakdown-tag--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
268 changes: 268 additions & 0 deletions frontend/src/layout/navigation-3000/components/AlgoliaSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { IconCheckCircle } from '@posthog/icons'
import { LemonButton, LemonInput, LemonTag } from '@posthog/lemon-ui'
import algoliasearch from 'algoliasearch/lite'
import { useActions } from 'kea'
import { useEffect, useRef, useState } from 'react'
import { InstantSearch, useHits, useRefinementList, useSearchBox } from 'react-instantsearch'
import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer'
import { List } from 'react-virtualized/dist/es/List'

import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
import { SidePanelTab } from '~/types'

const searchClient = algoliasearch('7VNQB5W0TX', '37f41fd37095bc85af76ed4edc85eb5a')

const rowRenderer = ({ key, index, style, hits, activeOption }: any): JSX.Element => {
const { slug, title, type, resolved } = hits[index]
return (
// eslint-disable-next-line react/forbid-dom-props
<li key={key} style={style} role="listitem" tabIndex={-1} className="p-1 border-b last:border-b-0">
<LemonButton
active={activeOption === index}
to={`https://posthog.com/${slug}`}
className="[&_>span>span]:flex-col [&_>span>span]:items-start [&_>span>span]:space-y-1"
>
<span>
<span className="flex space-x-2 items-center">
<p className="m-0 font-bold font-sans line-clamp-1">{title}</p>
{type === 'question' && resolved && (
<IconCheckCircle className="text-success size-4 flex-shrink-0" />
)}
</span>
<p className="text-xs m-0 opacity-80 font-normal font-sans line-clamp-1">/{slug}</p>
</span>
</LemonButton>
</li>
)
}

const Hits = ({ activeOption }: { activeOption?: number }): JSX.Element => {
const { hits } = useHits()
return (
<ol role="listbox" className="list-none m-0 p-0 h-[80vh]">
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
scrollToIndex={activeOption}
width={width}
height={height}
rowCount={hits.length}
rowHeight={50}
rowRenderer={(options: any) => rowRenderer({ ...options, hits, activeOption })}
/>
)}
</AutoSizer>
</ol>
)
}

const SearchInput = ({
value,
setValue,
}: {
value: string
setValue: React.Dispatch<React.SetStateAction<string>>
}): JSX.Element => {
const { refine } = useSearchBox()

const handleChange = (value: string): void => {
setValue(value)
refine(value)
}

return <LemonInput onChange={handleChange} value={value} type="search" fullWidth placeholder="Search..." />
}

type Tag = {
type: string
label: string
}

const tags: Tag[] = [
{
type: 'all',
label: 'All',
},
{
type: 'docs',
label: 'Docs',
},
{
type: 'question',
label: 'Questions',
},
{
type: 'tutorial',
label: 'Tutorials',
},
]

type SearchTagProps = Tag & {
active?: boolean
onClick: (type: string) => void
}

const SearchTag = ({ type, label, active, onClick }: SearchTagProps): JSX.Element => {
const { refine, items } = useRefinementList({ attribute: 'type' })
const itemCount = type !== 'all' && items.find(({ value }) => value === type)?.count

const handleClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
e.stopPropagation()
onClick(type)
}

useEffect(() => {
refine(type)
}, [])

return (
<button className="p-0 cursor-pointer bg-bg-light" onClick={handleClick}>
<LemonTag size="medium" type={active ? 'primary' : 'option'}>
<span>{label}</span>
{type !== 'all' && <span>({itemCount ?? 0})</span>}
</LemonTag>
</button>
)
}

const Tags = ({
activeTag,
setActiveTag,
}: {
activeTag: string
setActiveTag: React.Dispatch<React.SetStateAction<string>>
}): JSX.Element => {
const handleClick = (type: string): void => {
setActiveTag(type)
}

return (
<ul className="list-none m-0 p-0 flex space-x-1 mt-1 mb-0.5 pb-1.5 border-b px-2">
{tags.map((tag) => {
const { type } = tag
return (
<li key={type}>
<SearchTag {...tag} active={activeTag === type} onClick={handleClick} />
</li>
)
})}
</ul>
)
}

const Search = (): JSX.Element => {
const { openSidePanel } = useActions(sidePanelStateLogic)
const { hits } = useHits()
const { items, refine } = useRefinementList({ attribute: 'type' })

const ref = useRef<HTMLDivElement>(null)
const [searchValue, setSearchValue] = useState<string>('')
const [activeOption, setActiveOption] = useState<undefined | number>()
const [activeTag, setActiveTag] = useState('all')
const [searchOpen, setSearchOpen] = useState(false)

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
switch (e.key) {
case 'Enter': {
if (activeOption !== undefined) {
openSidePanel(SidePanelTab.Docs, `https://posthog.com/${hits[activeOption].slug}`)
}
break
}

case 'Escape': {
setSearchOpen(false)
break
}
case 'ArrowDown': {
e.preventDefault()
setActiveOption((currOption) => {
if (currOption === undefined || currOption >= hits.length - 1) {
return 0
}
return currOption + 1
})
break
}
case 'ArrowUp': {
e.preventDefault()
setActiveOption((currOption) => {
if (currOption !== undefined) {
return currOption <= 0 ? hits.length - 1 : currOption - 1
}
})
break
}
case 'Tab':
case 'ArrowRight': {
e.preventDefault()
const currTagIndex = tags.findIndex(({ type }) => type === activeTag)
setActiveTag(tags[currTagIndex >= tags.length - 1 ? 0 : currTagIndex + 1].type)
break
}
case 'ArrowLeft': {
e.preventDefault()
const currTagIndex = tags.findIndex(({ type }) => type === activeTag)
setActiveTag(tags[currTagIndex <= 0 ? tags.length - 1 : currTagIndex - 1].type)
}
}
}

useEffect(() => {
setSearchOpen(!!searchValue)
setActiveOption(0)
}, [searchValue])

useEffect(() => {
setActiveOption(0)
if (activeTag === 'all') {
const filteredItems = items.filter(({ value }) => tags.some(({ type }) => type === value))
filteredItems.forEach(({ value, isRefined }) => {
if (!isRefined) {
refine(value)
}
})
} else {
items.forEach(({ value, isRefined }) => {
if (isRefined) {
refine(value)
}
})
refine(activeTag)
}
}, [activeTag])

useEffect(() => {
const handleClick = (e: any): void => {
if (!ref?.current?.contains(e.target)) {
setSearchOpen(false)
}
}

window.addEventListener('click', handleClick)

return () => {
window.removeEventListener('click', handleClick)
}
}, [])

return (
<div className="relative" ref={ref} onKeyDown={handleKeyDown}>
<SearchInput value={searchValue} setValue={setSearchValue} />
{searchOpen && (
<div className="absolute w-full bg-bg-light z-50 border rounded-lg shadow-xl mt-0.5">
<Tags activeTag={activeTag} setActiveTag={setActiveTag} />
<Hits activeOption={activeOption} />
</div>
)}
</div>
)
}

export default function AlgoliaSearch(): JSX.Element {
return (
<InstantSearch searchClient={searchClient} indexName="prod_posthog_com">
<Search />
</InstantSearch>
)
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Meta, StoryFn } from '@storybook/react'
import { useActions } from 'kea'
import { router } from 'kea-router'
import { supportLogic } from 'lib/components/Support/supportLogic'
import { useEffect } from 'react'
import { App } from 'scenes/App'
import { urls } from 'scenes/urls'

import { mswDecorator } from '~/mocks/browser'
import { mswDecorator, useStorybookMocks } from '~/mocks/browser'
import organizationCurrent from '~/mocks/fixtures/api/organizations/@current/@current.json'
import { SidePanelTab } from '~/types'

import { sidePanelStateLogic } from './sidePanelStateLogic'
Expand Down Expand Up @@ -59,3 +61,36 @@ export const SidePanelActivation: StoryFn = () => {
export const SidePanelNotebooks: StoryFn = () => {
return <BaseTemplate panel={SidePanelTab.Notebooks} />
}

export const SidePanelSupportNoEmail: StoryFn = () => {
return <BaseTemplate panel={SidePanelTab.Support} />
}

export const SidePanelSupportWithEmail: StoryFn = () => {
const { openEmailForm } = useActions(supportLogic)
useStorybookMocks({
get: {
// TODO: setting available featues should be a decorator to make this easy
'/api/users/@me': () => [
200,
{
email: '[email protected]',
first_name: 'Test Hedgehog',
organization: {
...organizationCurrent,
available_product_features: [
{
key: 'email_support',
name: 'Email support',
},
],
},
},
],
},
})
useEffect(() => {
openEmailForm()
}, [])
return <BaseTemplate panel={SidePanelTab.Support} />
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const SIDE_PANEL_TABS: Record<
noModalSupport: true,
},
[SidePanelTab.Support]: {
label: 'Support',
label: 'Help',
Icon: IconSupport,
Content: SidePanelSupport,
},
Expand Down
Loading

0 comments on commit c8545b9

Please sign in to comment.