0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-24 18:07:17 +01:00

chore(support): Improve bug/feedback/support modal experience (#15312)

* Use proper beta tag for sampling

* Auto scroll to selected item in menu

* Revamp support modal

* Add file uploads

* Fix modal footer alignment

* Update UI snapshots for `chromium` (1)

* Update UI snapshots for `chromium` (2)

* Update toast message

* Update kea-typegen to fix logic type

* Update frontend/src/lib/components/Support/supportLogic.ts

Co-authored-by: Tiina Turban <tiina303@gmail.com>

* Try to fix pnpm cache in CI

* Fix typing

* Refactor away nonsense selectors in `experimentsLogic`

* Refactor away more selectors with empty deps

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tiina Turban <tiina303@gmail.com>
This commit is contained in:
Michael Matloka 2023-05-04 13:32:52 +02:00 committed by GitHub
parent a5544cf7e4
commit 4f9c9e7a72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 378 additions and 261 deletions

View File

@ -30,7 +30,17 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: 18
cache: pnpm
- name: Get pnpm cache directory path
id: pnpm-cache-dir
run: echo "PNPM_STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
id: pnpm-cache
with:
path: ${{ steps.pnpm-cache-dir.outputs.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-cypress-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-pnpm-cypress-
- name: Install package.json dependencies with pnpm
run: pnpm install --frozen-lockfile

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -282,7 +282,7 @@ export function SideBar({ children }: { children: React.ReactNode }): JSX.Elemen
function AppUrls({ setIsToolbarLaunchShown }: { setIsToolbarLaunchShown: (state: boolean) => void }): JSX.Element {
const { authorizedUrls, launchUrl, suggestionsLoading } = useValues(
authorizedUrlListLogic({ type: AuthorizedUrlListType.TOOLBAR_URLS })
authorizedUrlListLogic({ type: AuthorizedUrlListType.TOOLBAR_URLS, actionId: null })
)
return (
<div className="SideBar__side-actions" data-attr="sidebar-launch-toolbar">

View File

@ -2,11 +2,7 @@ import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import {
AuthorizedUrlListType as AuthorizedUrlListType,
authorizedUrlListLogic,
AuthorizedUrlListProps,
} from './authorizedUrlListLogic'
import { AuthorizedUrlListType as AuthorizedUrlListType, authorizedUrlListLogic } from './authorizedUrlListLogic'
import { IconDelete, IconEdit, IconOpenInApp, IconPlus } from 'lib/lemon-ui/icons'
import { Spinner } from 'lib/lemon-ui/Spinner/Spinner'
import { Form } from 'kea-forms'
@ -48,7 +44,7 @@ function EmptyState({
}
function AuthorizedUrlForm({ actionId, type }: AuthorizedUrlListProps): JSX.Element {
const logic = authorizedUrlListLogic({ actionId, type })
const logic = authorizedUrlListLogic({ actionId: actionId ?? null, type })
const { isProposedUrlSubmitting } = useValues(logic)
const { cancelProposingUrl } = useActions(logic)
return (
@ -78,12 +74,17 @@ function AuthorizedUrlForm({ actionId, type }: AuthorizedUrlListProps): JSX.Elem
)
}
export interface AuthorizedUrlListProps {
actionId?: number
type: AuthorizedUrlListType
}
export function AuthorizedUrlList({
actionId,
type,
addText = 'Add',
}: AuthorizedUrlListProps & { addText?: string }): JSX.Element {
const logic = authorizedUrlListLogic({ actionId, type })
const logic = authorizedUrlListLogic({ actionId: actionId ?? null, type })
const {
urlsKeyed,
suggestionsLoading,

View File

@ -28,6 +28,7 @@ describe('the authorized urls list logic', () => {
initKeaTests()
logic = authorizedUrlListLogic({
type: AuthorizedUrlListType.TOOLBAR_URLS,
actionId: null,
})
logic.mount()
})
@ -106,6 +107,7 @@ describe('the authorized urls list logic', () => {
beforeEach(() => {
logic = authorizedUrlListLogic({
type: AuthorizedUrlListType.RECORDING_DOMAINS,
actionId: null,
})
logic.mount()
})

View File

@ -62,19 +62,18 @@ export const validateProposedURL = (
}
/** defaultIntent: whether to launch with empty intent (i.e. toolbar mode is default) */
export function appEditorUrl(appUrl?: string, actionId?: number, defaultIntent?: boolean): string {
// See
// https://github.com/PostHog/posthog-js/blob/f7119c7542c940354719a9ba8120a08ba25b5ae8/src/extensions/toolbar.ts#L52
// for where these params are passed.
const params: ToolbarParams = {
export function appEditorUrl(appUrl: string, actionId?: number | null, defaultIntent?: boolean): string {
// See https://github.com/PostHog/posthog-js/blob/f7119c/src/extensions/toolbar.ts#L52 for where these params
// are passed. `appUrl` is an extra `redirect_to_site` param.
const params: ToolbarParams & { appUrl: string } = {
userIntent: defaultIntent ? undefined : actionId ? 'edit-action' : 'add-action',
// Make sure to pass the app url, otherwise the api_host will be used by
// the toolbar, which isn't correct when used behind a reverse proxy as
// we require e.g. SSO login to the app, which will not work when placed
// behind a proxy unless we register each domain with the OAuth2 client.
apiURL: window.location.origin,
appUrl,
...(actionId ? { actionId } : {}),
...(appUrl ? { appUrl } : {}),
}
return '/api/user/redirect_to_site/' + encodeParams(params, '?')
}
@ -87,14 +86,14 @@ export interface KeyedAppUrl {
originalIndex: number
}
export interface AuthorizedUrlListProps {
actionId?: number
export interface AuthorizedUrlListLogicProps {
actionId: number | null
type: AuthorizedUrlListType
}
export const authorizedUrlListLogic = kea<authorizedUrlListLogicType>([
path((key) => ['lib', 'components', 'AuthorizedUrlList', 'authorizedUrlListLogic', key]),
key((props) => `${props.type}-${props.actionId}`),
props({} as AuthorizedUrlListProps),
props({} as AuthorizedUrlListLogicProps),
connect({
values: [teamLogic, ['currentTeam', 'currentTeamId']],
actions: [teamLogic, ['updateCurrentTeam']],
@ -280,7 +279,7 @@ export const authorizedUrlListLogic = kea<authorizedUrlListLogicType>([
actions.resetProposedUrl()
},
})),
selectors(({ props }) => ({
selectors({
urlToEdit: [
(s) => [s.authorizedUrls, s.editUrlIndex],
(authorizedUrls, editUrlIndex) => {
@ -319,10 +318,10 @@ export const authorizedUrlListLogic = kea<authorizedUrlListLogicType>([
.map((result) => result.item)
},
],
launchUrl: [() => [], () => (url: string) => appEditorUrl(url, props.actionId, !props.actionId)],
launchUrl: [(_, p) => [p.actionId], (actionId) => (url: string) => appEditorUrl(url, actionId, !actionId)],
isAddUrlFormVisible: [(s) => [s.editUrlIndex], (editUrlIndex) => editUrlIndex === -1],
onlyAllowDomains: [() => [], () => props.type === AuthorizedUrlListType.RECORDING_DOMAINS],
})),
onlyAllowDomains: [(_, p) => [p.type], (type) => type === AuthorizedUrlListType.RECORDING_DOMAINS],
}),
urlToAction(({ actions }) => ({
[urls.toolbarLaunch()]: (_, searchParams) => {
if (searchParams.addNew) {

View File

@ -3,27 +3,26 @@ import { kea, useActions, useValues } from 'kea'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { HelpType } from '~/types'
import type { helpButtonLogicType } from './HelpButtonType'
import { Popover } from 'lib/lemon-ui/Popover/Popover'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import {
IconArrowDropDown,
IconArticle,
IconHelpOutline,
IconMail,
IconQuestionAnswer,
IconMessages,
IconFlare,
IconTrendingUp,
IconLive,
IconSupport,
IconFeedback,
IconBugReport,
} from 'lib/lemon-ui/icons'
import clsx from 'clsx'
import { Placement } from '@floating-ui/react'
import { DefaultAction, inAppPromptLogic } from 'lib/logic/inAppPrompt/inAppPromptLogic'
import { hedgehogbuddyLogic } from '../HedgehogBuddy/hedgehogbuddyLogic'
import { HedgehogBuddyWithLogic } from '../HedgehogBuddy/HedgehogBuddy'
import { navigationLogic } from '~/layout/navigation/navigationLogic'
import { supportLogic } from '../Support/supportLogic'
import SupportForm from '../Support/SupportForm'
import { SupportModal } from '../Support/SupportModal'
import { LemonMenu } from 'lib/lemon-ui/LemonMenu'
const HELP_UTM_TAGS = '?utm_medium=in-product&utm_campaign=help-button-top'
@ -88,111 +87,103 @@ export function HelpButton({
const { isPromptVisible } = useValues(inAppPromptLogic)
const { hedgehogModeEnabled } = useValues(hedgehogbuddyLogic)
const { setHedgehogModeEnabled } = useActions(hedgehogbuddyLogic)
const { toggleActivationSideBar } = useActions(navigationLogic)
const { openSupportForm } = useActions(supportLogic)
return (
<>
<Popover
overlay={
<>
{!contactOnly && (
<LemonButton
icon={<IconLive />}
status="stealth"
fullWidth
onClick={() => {
<LemonMenu
items={[
!contactOnly && {
items: [
{
icon: <IconLive />,
label: "What's new?",
onClick: () => {
reportHelpButtonUsed(HelpType.Updates)
hideHelp()
}}
to={`https://posthog.com/blog/categories/posthog-news`}
targetBlank
>
What's new?
</LemonButton>
)}
<LemonButton
icon={<IconMail />}
status="stealth"
fullWidth
onClick={() => {
reportHelpButtonUsed(HelpType.SupportForm)
openSupportForm()
hideHelp()
}}
>
Report bug / get support
</LemonButton>
{!contactOnly && (
<LemonButton
icon={<IconArticle />}
status="stealth"
fullWidth
onClick={() => {
},
to: 'https://posthog.com/blog/categories/posthog-news',
targetBlank: true,
},
],
},
{
items: [
{
label: 'Ask on the forum',
icon: <IconQuestionAnswer />,
onClick: () => {
reportHelpButtonUsed(HelpType.Slack)
hideHelp()
},
to: `https://posthog.com/questions${HELP_UTM_TAGS}`,
targetBlank: true,
},
{
label: 'Report a bug',
icon: <IconBugReport />,
onClick: () => {
reportHelpButtonUsed(HelpType.SupportForm)
openSupportForm('bug')
hideHelp()
},
},
{
label: 'Give feedback',
icon: <IconFeedback />,
onClick: () => {
reportHelpButtonUsed(HelpType.SupportForm)
openSupportForm('feedback')
hideHelp()
},
},
{
label: 'Get support',
icon: <IconSupport />,
onClick: () => {
reportHelpButtonUsed(HelpType.SupportForm)
openSupportForm('support')
hideHelp()
},
},
],
},
!contactOnly && {
items: [
{
label: 'Read the docs',
icon: <IconArticle />,
onClick: () => {
reportHelpButtonUsed(HelpType.Docs)
hideHelp()
}}
to={`https://posthog.com/docs${HELP_UTM_TAGS}`}
targetBlank
>
Read the docs
</LemonButton>
)}
<LemonButton
icon={<IconQuestionAnswer />}
status="stealth"
fullWidth
onClick={() => {
reportHelpButtonUsed(HelpType.Slack)
hideHelp()
}}
to={`https://posthog.com/questions${HELP_UTM_TAGS}`}
targetBlank
>
Ask a question on our forum
</LemonButton>
<LemonButton
icon={<IconTrendingUp />}
status="stealth"
fullWidth
onClick={() => {
toggleActivationSideBar()
hideHelp()
}}
>
Open Quick Start
</LemonButton>
{validProductTourSequences.length > 0 && (
<LemonButton
icon={<IconMessages />}
status="stealth"
fullWidth
onClick={() => {
},
to: `https://posthog.com/docs${HELP_UTM_TAGS}`,
targetBlank: true,
},
validProductTourSequences.length > 0 && {
label: isPromptVisible ? 'Stop tutorial' : 'Explain this page',
icon: <IconMessages />,
onClick: () => {
if (isPromptVisible) {
promptAction(DefaultAction.SKIP)
} else {
runFirstValidSequence({ runDismissedOrCompleted: true })
}
hideHelp()
}}
>
{isPromptVisible ? 'Stop tutorial' : 'Explain this page'}
</LemonButton>
)}
<LemonButton
icon={<IconFlare />}
status="stealth"
fullWidth
onClick={() => {
setHedgehogModeEnabled(!hedgehogModeEnabled)
hideHelp()
}}
>
{hedgehogModeEnabled ? 'Disable' : 'Enable'} Hedgehog Mode
</LemonButton>
</>
}
onClickOutside={hideHelp}
},
},
{
label: `${hedgehogModeEnabled ? 'Disable' : 'Enable'} hedgehog mode`,
icon: <IconFlare />,
onClick: () => {
setHedgehogModeEnabled(!hedgehogModeEnabled)
hideHelp()
},
},
],
},
]}
onVisibilityChange={(visible) => !visible && hideHelp()}
visible={isHelpVisible}
placement={placement}
actionable
@ -205,9 +196,9 @@ export function HelpButton({
</>
)}
</div>
</Popover>
</LemonMenu>
<HedgehogBuddyWithLogic />
<SupportForm />
<SupportModal />
</>
)
}

View File

@ -1,65 +0,0 @@
import { useActions, useValues } from 'kea'
import { TargetAreaToName, supportLogic } from './supportLogic'
import { Form } from 'kea-forms'
import { LemonButton } from 'lib/lemon-ui/LemonButton/LemonButton'
import { LemonModal } from 'lib/lemon-ui/LemonModal/LemonModal'
import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea/LemonTextArea'
import { LemonSelect } from 'lib/lemon-ui/LemonSelect/LemonSelect'
import { Field } from 'lib/forms/Field'
import { capitalizeFirstLetter } from 'lib/utils'
export default function SupportForm(): JSX.Element {
const { isSupportFormOpen } = useValues(supportLogic)
const { closeSupportForm } = useActions(supportLogic)
return (
<LemonModal
isOpen={isSupportFormOpen}
onClose={closeSupportForm}
title={'Bug / Feedback'}
description="We're using aggregate bug reports for prioritization and won't get back to everyone individually."
footer={
<div className="flex-1 flex items-center justify-between">
<div className="flex items-center gap-2">
<LemonButton form="support-modal-form" type="secondary" onClick={closeSupportForm}>
Cancel
</LemonButton>
<LemonButton form="support-modal-form" htmlType="submit" type="primary" data-attr="submit">
Submit
</LemonButton>
</div>
</div>
}
>
<Form
logic={supportLogic}
formKey="sendSupportRequest"
id="support-modal-form"
enableFormOnSubmit
className="space-y-4"
>
<Field name="kind" label="What kind of request is this?">
<LemonSelect
fullWidth
options={['bug', 'feedback'].map((key) => ({
value: key,
label: capitalizeFirstLetter(key),
}))}
/>
</Field>
<Field name="target_area" label="What area does it best relate to?">
<LemonSelect
fullWidth
options={Object.entries(TargetAreaToName).map(([key, value]) => ({
label: value,
value: key,
}))}
/>
</Field>
<Field name="message" label="Content">
<LemonTextArea placeholder="Type your message here" data-attr="support-form-content-input" />
</Field>
</Form>
</LemonModal>
)
}

View File

@ -0,0 +1,125 @@
import { useActions, useValues } from 'kea'
import { SupportTicketKind, TARGET_AREA_TO_NAME, supportLogic } from './supportLogic'
import { Form } from 'kea-forms'
import { LemonButton } from 'lib/lemon-ui/LemonButton/LemonButton'
import { LemonModal } from 'lib/lemon-ui/LemonModal/LemonModal'
import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea/LemonTextArea'
import { LemonSelect, LemonSelectOptions } from 'lib/lemon-ui/LemonSelect/LemonSelect'
import { Field } from 'lib/forms/Field'
import { IconBugReport, IconFeedback, IconSupport } from 'lib/lemon-ui/icons'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { LemonFileInput, useUploadFiles } from 'lib/lemon-ui/LemonFileInput/LemonFileInput'
import { useRef } from 'react'
import { lemonToast } from '@posthog/lemon-ui'
const SUPPORT_TICKET_OPTIONS: LemonSelectOptions<SupportTicketKind> = [
{
value: 'bug',
label: 'Bug',
icon: <IconBugReport />,
},
{
value: 'feedback',
label: 'Feedback',
icon: <IconFeedback />,
},
{
value: 'support',
label: 'Support',
icon: <IconSupport />,
},
]
const SUPPORT_TICKET_KIND_TO_TITLE: Record<SupportTicketKind, string> = {
bug: 'Report a bug',
feedback: 'Give feedback',
support: 'Get support',
}
const SUPPORT_TICKET_KIND_TO_PROMPT: Record<SupportTicketKind, string> = {
bug: "What's the bug?",
feedback: 'What feedback do you have?',
support: 'What can we help you with?',
}
export function SupportModal(): JSX.Element {
const { sendSupportRequest, isSupportFormOpen } = useValues(supportLogic)
const { setSendSupportRequestValue, closeSupportForm } = useActions(supportLogic)
const { objectStorageAvailable } = useValues(preflightLogic)
const dropRef = useRef<HTMLDivElement>(null)
const { setFilesToUpload, filesToUpload, uploading } = useUploadFiles({
onUpload: (url, fileName) => {
setSendSupportRequestValue('message', sendSupportRequest.message + `\n\n![${fileName}](${url})`)
},
onError: (detail) => {
lemonToast.error(`Error uploading image: ${detail}`)
},
})
return (
<LemonModal
isOpen={isSupportFormOpen}
onClose={closeSupportForm}
title={
sendSupportRequest.kind
? SUPPORT_TICKET_KIND_TO_TITLE[sendSupportRequest.kind]
: 'Leave a message with PostHog'
}
footer={
<div className="flex items-center gap-2">
<LemonButton form="support-modal-form" type="secondary" onClick={closeSupportForm}>
Cancel
</LemonButton>
<LemonButton form="support-modal-form" htmlType="submit" type="primary" data-attr="submit">
Submit
</LemonButton>
</div>
}
>
<Form
logic={supportLogic}
formKey="sendSupportRequest"
id="support-modal-form"
enableFormOnSubmit
className="space-y-4"
>
<Field name="kind" label="What type of message is this?">
<LemonSelect fullWidth options={SUPPORT_TICKET_OPTIONS} />
</Field>
<Field name="target_area" label="What area does this best relate to?">
<LemonSelect
fullWidth
options={Object.entries(TARGET_AREA_TO_NAME).map(([key, value]) => ({
label: value,
value: key,
}))}
/>
</Field>
<Field
name="message"
label={sendSupportRequest.kind ? SUPPORT_TICKET_KIND_TO_PROMPT[sendSupportRequest.kind] : 'Content'}
>
{(props) => (
<div ref={dropRef} className="flex flex-col gap-2">
<LemonTextArea
placeholder="Type your message here"
data-attr="support-form-content-input"
{...props}
/>
{objectStorageAvailable && (
<LemonFileInput
accept="image/*"
multiple={false}
alternativeDropTargetRef={dropRef}
onChange={setFilesToUpload}
loading={uploading}
value={filesToUpload}
/>
)}
</div>
)}
</Field>
</Form>
</LemonModal>
)
}

View File

@ -8,6 +8,7 @@ import { forms } from 'kea-forms'
import { UserType } from '~/types'
import { lemonToast } from 'lib/lemon-ui/lemonToast'
import { actionToUrl, router, urlToAction } from 'kea-router'
import { captureException } from '@sentry/react'
function getSessionReplayLink(): string {
const LOOK_BACK = 30
@ -28,24 +29,24 @@ function getDjangoAdminLink(user: UserType | null): string {
return `\nAdmin link: ${link} (Organization: '${user.organization?.name}'; Project: '${user.team?.name}')`
}
export const TargetAreaToName = {
analytics: 'Analytics',
export const TARGET_AREA_TO_NAME = {
app_performance: 'App Performance',
apps: 'Apps',
login: 'Authentication (Login / Sign-up / Invites)',
billing: 'Billing',
cohorts: 'Cohorts',
data_management: 'Data Management',
data_integrity: 'Data Integrity',
ingestion: 'Events Ingestion',
data_management: 'Data Management',
ingestion: 'Event Ingestion',
experiments: 'Experiments',
feature_flags: 'Feature Flags',
login: 'Login / Sign up / Invites',
session_replay: 'Session Replay',
analytics: 'Product Analytics (Insights, Dashboards, Annotations)',
session_replay: 'Session Replay (Recordings)',
}
export type supportTicketTargetArea = keyof typeof TargetAreaToName | null
export type supportTicketKind = 'bug' | 'feedback' | null
export type SupportTicketTargetArea = keyof typeof TARGET_AREA_TO_NAME
export type SupportTicketKind = 'bug' | 'feedback' | 'support'
export const URLPathToTargetArea: Record<string, supportTicketTargetArea> = {
export const URL_PATH_TO_TARGET_AREA: Record<string, SupportTicketTargetArea> = {
insights: 'analytics',
recordings: 'session_replay',
replay: 'session_replay',
@ -63,9 +64,9 @@ export const URLPathToTargetArea: Record<string, supportTicketTargetArea> = {
toolbar: 'analytics',
}
export function getURLPathToTargetArea(pathname: string): supportTicketTargetArea | null {
export function getURLPathToTargetArea(pathname: string): SupportTicketTargetArea | null {
const first_part = pathname.split('/')[1]
return URLPathToTargetArea[first_part] ?? null
return URL_PATH_TO_TARGET_AREA[first_part] ?? null
}
export const supportLogic = kea<supportLogicType>([
@ -75,11 +76,18 @@ export const supportLogic = kea<supportLogicType>([
})),
actions(() => ({
closeSupportForm: () => true,
openSupportForm: (kind: supportTicketKind = null, target_area: supportTicketTargetArea = null) => ({
openSupportForm: (
kind: SupportTicketKind | null = null,
target_area: SupportTicketTargetArea | null = null
) => ({
kind,
target_area,
}),
submitZendeskTicket: (kind: supportTicketKind, target_area: supportTicketTargetArea, message: string) => ({
submitZendeskTicket: (
kind: SupportTicketKind | null,
target_area: SupportTicketTargetArea | null,
message: string
) => ({
kind,
target_area,
message,
@ -97,8 +105,8 @@ export const supportLogic = kea<supportLogicType>([
forms(({ actions }) => ({
sendSupportRequest: {
defaults: {} as unknown as {
kind: supportTicketKind
target_area: supportTicketTargetArea
kind: SupportTicketKind | null
target_area: SupportTicketTargetArea | null
message: string
},
errors: ({ message, kind, target_area }) => {
@ -162,12 +170,13 @@ export const supportLogic = kea<supportLogicType>([
}
posthog.capture('support_ticket', properties)
lemonToast.success(
'Got it! The relevant team will check it out and aim to respond via email if necessary.'
"Got the message! If we have follow-up information for you, we'll reply via email."
)
})
.catch((err) => {
captureException(err)
console.log(err)
lemonToast.error('Failed to submit form.')
lemonToast.error(`There was an error sending the message.`)
})
},
})),
@ -179,7 +188,7 @@ export const supportLogic = kea<supportLogicType>([
actions.openSupportForm(
['bug', 'feedback'].includes(kind) ? kind : null,
Object.keys(TargetAreaToName).includes(area) ? area : null
Object.keys(TARGET_AREA_TO_NAME).includes(area) ? area : null
)
}
},

View File

@ -179,6 +179,7 @@ export const LemonFileInput = ({
onChange={onInputChange}
/>
<IconUploadFile className={'text-2xl'} /> Click or drag and drop to upload
{accept ? ` ${acceptToDisplayName(accept)}` : ''}
</label>
<div className={'flex flex-row gap-2'}>
{files.map((x, i) => (
@ -196,3 +197,14 @@ const lazyImageBlobReducer = async (blob: Blob): Promise<Blob> => {
const blobReducer = (await import('image-blob-reduce')).default()
return blobReducer.toBlob(blob, { max: 2000 })
}
function acceptToDisplayName(accept: string): string {
const match = accept.match(/(\w+)\/\*/)
if (match) {
return `${match[1]}s`
}
if (accept.startsWith('.')) {
return `${accept.slice(1).toUpperCase()} files`
}
return `files`
}

View File

@ -17,7 +17,7 @@ export interface LemonMenuItemBase
label: string | JSX.Element
}
export interface LemonMenuItemNode extends LemonMenuItemBase {
items: LemonMenuItemLeaf[]
items: (LemonMenuItemLeaf | false | null)[]
}
export type LemonMenuItemLeaf =
| (LemonMenuItemBase & {
@ -25,20 +25,22 @@ export type LemonMenuItemLeaf =
})
| (LemonMenuItemBase & {
to: string
targetBlank?: boolean
})
| (LemonMenuItemBase & {
onClick: () => void
to: string
targetBlank?: boolean
})
export type LemonMenuItem = LemonMenuItemLeaf | LemonMenuItemNode
export interface LemonMenuSection {
title?: string | React.ReactNode
items: LemonMenuItem[]
items: (LemonMenuItem | false | null)[]
footer?: string | React.ReactNode
}
export type LemonMenuItems = (LemonMenuItem | LemonMenuSection)[]
export type LemonMenuItems = (LemonMenuItem | LemonMenuSection | false | null)[]
export interface LemonMenuProps
extends Pick<
@ -56,13 +58,19 @@ export interface LemonMenuProps
LemonMenuOverlayProps {
/** Must support `ref` and `onKeyDown` for keyboard navigation. */
children: React.ReactElement
/** Optional index of the active (e.g. selected) item. This improves the keyboard navigation experience. */
/** Index of the active (e.g. selected) item, if there is a specific one. */
activeItemIndex?: number
}
export function LemonMenu({ items, activeItemIndex, tooltipPlacement, ...dropdownProps }: LemonMenuProps): JSX.Element {
export function LemonMenu({
items,
activeItemIndex,
tooltipPlacement,
onVisibilityChange,
...dropdownProps
}: LemonMenuProps): JSX.Element {
const { referenceRef, itemsRef } = useKeyboardNavigation<HTMLElement, HTMLButtonElement>(
items.flatMap((item) => (isLemonMenuSection(item) ? item.items : item)).length,
items.flatMap((item) => (item && isLemonMenuSection(item) ? item.items : item)).length,
activeItemIndex
)
@ -71,6 +79,16 @@ export function LemonMenu({ items, activeItemIndex, tooltipPlacement, ...dropdow
overlay={<LemonMenuOverlay items={items} tooltipPlacement={tooltipPlacement} itemsRef={itemsRef} />}
closeOnClickInside
referenceRef={referenceRef}
onVisibilityChange={(visible) => {
onVisibilityChange?.(visible)
if (visible && activeItemIndex && activeItemIndex > -1) {
// Scroll the active item into view once the menu is open (i.e. in the next tick)
setTimeout(
() => itemsRef?.current?.[activeItemIndex]?.current?.scrollIntoView({ block: 'center' }),
0
)
}
}}
{...dropdownProps}
/>
)
@ -135,7 +153,7 @@ export function LemonMenuSectionList({
)
) : null}
<LemonMenuItemList
items={section.items}
items={section.items.filter(Boolean) as LemonMenuItem[]}
buttonSize={buttonSize}
tooltipPlacement={tooltipPlacement}
itemsRef={itemsRef}
@ -220,10 +238,13 @@ const LemonMenuItemButton: FunctionComponent<LemonMenuItemButtonProps & React.Re
})
LemonMenuItemButton.displayName = 'LemonMenuItemButton'
function normalizeItems(sectionsAndItems: (LemonMenuItem | LemonMenuSection)[]): LemonMenuItem[] | LemonMenuSection[] {
function normalizeItems(sectionsAndItems: LemonMenuItems): LemonMenuItem[] | LemonMenuSection[] {
const sections: LemonMenuSection[] = []
let implicitSection: LemonMenuSection = { items: [] }
for (const sectionOrItem of sectionsAndItems) {
if (!sectionOrItem) {
continue // Ignore falsy items
}
if (isLemonMenuSection(sectionOrItem)) {
if (implicitSection.items.length > 0) {
sections.push(implicitSection)
@ -239,7 +260,7 @@ function normalizeItems(sectionsAndItems: (LemonMenuItem | LemonMenuSection)[]):
}
if (sections.length === 1 && !sections[0].title && !sections[0].footer) {
return sections[0].items
return sections[0].items.filter(Boolean) as LemonMenuItem[]
}
return sections
}

View File

@ -50,7 +50,7 @@ export function useKeyboardNavigation<R extends HTMLElement = HTMLElement, I ext
item.current?.removeEventListener('keydown', handleKeyDown)
}
}
})
}, [focusedItemIndex, itemCount])
return { referenceRef, itemsRef }
}

View File

@ -11,7 +11,6 @@ import {
LemonMenuItemBase,
LemonMenuItemLeaf,
LemonMenuItemNode,
LemonMenuItems,
LemonMenuProps,
LemonMenuSection,
isLemonMenuSection,
@ -119,7 +118,9 @@ export function LemonSelect<T>({
actionable
className={menu?.className}
maxContentWidth={dropdownMaxContentWidth}
activeItemIndex={items.flatMap((i) => (isLemonMenuSection(i) ? i.items : i)).findIndex((i) => i.active)}
activeItemIndex={items
.flatMap((i) => (isLemonMenuSection(i) ? i.items.filter(Boolean) : i))
.findIndex((i) => (i as LemonMenuItem).active)}
closeParentPopoverOnClickInside={menu?.closeParentPopoverOnClickInside}
>
<LemonButton
@ -165,9 +166,9 @@ function convertSelectOptionsToMenuItems<T>(
options: LemonSelectOptions<T>,
activeValue: T | null,
onSelect: OnSelect<T>
): [LemonMenuItems, LemonSelectOptionLeaf<T>[]] {
): [(LemonMenuItem | LemonMenuSection)[], LemonSelectOptionLeaf<T>[]] {
const leafOptionsAccumulator: LemonSelectOptionLeaf<T>[] = []
const items: LemonMenuItems = options.map((option) =>
const items: (LemonMenuItem | LemonMenuSection)[] = options.map((option) =>
convertToMenuSingle(option, activeValue, onSelect, leafOptionsAccumulator)
)
return [items, leafOptionsAccumulator]

View File

@ -62,9 +62,10 @@ export const LemonTextArea = React.forwardRef<HTMLTextAreaElement, LemonTextArea
})
interface LemonTextMarkdownProps {
'data-attr'?: string
value?: string
onChange?: (s: string) => void
placeholder?: string
'data-attr'?: string
}
export function LemonTextMarkdown({ value, onChange, ...editAreaProps }: LemonTextMarkdownProps): JSX.Element {

View File

@ -235,8 +235,8 @@ export function IconTrendUp(props: LemonIconProps): JSX.Element {
)
}
/** Material Design Announcement icon. */
export function IconFeedbackWarning(props: LemonIconProps): JSX.Element {
/** Material Design Feedback / Announcement icon. */
export function IconFeedback(props: LemonIconProps): JSX.Element {
return (
<LemonIconBase viewBox="0 0 32 32" {...props}>
<path
@ -787,6 +787,18 @@ export function IconExclamation(props: LemonIconProps): JSX.Element {
)
}
/** Material Design Support icon. */
export function IconSupport(props: LemonIconProps): JSX.Element {
return (
<LemonIconBase {...props}>
<path
d="M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M19.46,9.12l-2.78,1.15 c-0.51-1.36-1.58-2.44-2.95-2.94l1.15-2.78C16.98,5.35,18.65,7.02,19.46,9.12z M12,15c-1.66,0-3-1.34-3-3s1.34-3,3-3s3,1.34,3,3 S13.66,15,12,15z M9.13,4.54l1.17,2.78c-1.38,0.5-2.47,1.59-2.98,2.97L4.54,9.13C5.35,7.02,7.02,5.35,9.13,4.54z M4.54,14.87 l2.78-1.15c0.51,1.38,1.59,2.46,2.97,2.96l-1.17,2.78C7.02,18.65,5.35,16.98,4.54,14.87z M14.88,19.46l-1.15-2.78 c1.37-0.51,2.45-1.59,2.95-2.97l2.78,1.17C18.65,16.98,16.98,18.65,14.88,19.46z"
fill="currentColor"
/>
</LemonIconBase>
)
}
/** Material Design Push Pin icon, outlined. */
export function IconPinOutline(props: LemonIconProps): JSX.Element {
return (
@ -2362,6 +2374,16 @@ export function IconDragHandle(props: LemonIconProps): JSX.Element {
</LemonIconBase>
)
}
export function IconBugReport(props: LemonIconProps): JSX.Element {
return (
<LemonIconBase {...props}>
<path
d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-4 4v3c0 .22-.03.47-.07.7l-.1.65-.37.65c-.72 1.24-2.04 2-3.46 2s-2.74-.77-3.46-2l-.37-.64-.1-.65C8.03 15.48 8 15.23 8 15v-4c0-.23.03-.48.07-.7l.1-.65.37-.65c.3-.52.72-.97 1.21-1.31l.57-.39.74-.18c.31-.08.63-.12.94-.12.32 0 .63.04.95.12l.68.16.61.42c.5.34.91.78 1.21 1.31l.38.65.1.65c.04.22.07.47.07.69v1zm-6 2h4v2h-4zm0-4h4v2h-4z"
fill="currentColor"
/>
</LemonIconBase>
)
}
/** https://pictogrammers.com/library/mdi/icon/shield-bug-outline/ */
export function IconBugShield(props: LemonIconProps): JSX.Element {

View File

@ -212,17 +212,15 @@ export const appMetricsSceneLogic = kea<appMetricsSceneLogicType>([
() => INITIAL_TABS.filter((tab) => values.showTab(tab))[0] ?? AppMetricsTab.History,
],
currentTime: [() => [], () => Date.now()],
defaultDateFrom: [
(s) => [s.pluginConfig, s.currentTime],
(pluginConfig, currentTime) => {
(s) => [s.pluginConfig],
(pluginConfig) => {
if (!pluginConfig?.created_at) {
return DEFAULT_DATE_FROM
}
const installedAt = dayjs.utc(pluginConfig.created_at)
const daysSinceInstall = dayjs(currentTime).diff(installedAt, 'days', true)
const daysSinceInstall = dayjs().diff(installedAt, 'days', true)
if (daysSinceInstall <= 1) {
return '-24h'
} else if (daysSinceInstall <= 7) {

View File

@ -1,6 +1,6 @@
import { PageHeader } from 'lib/components/PageHeader'
import { SceneExport } from 'scenes/sceneTypes'
import { experimentsLogic } from './experimentsLogic'
import { experimentsLogic, getExperimentStatus } from './experimentsLogic'
import { useActions, useValues } from 'kea'
import { LemonTable, LemonTableColumn, LemonTableColumns } from 'lib/lemon-ui/LemonTable'
import { createdAtColumn, createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils'
@ -25,8 +25,7 @@ export const scene: SceneExport = {
}
export function Experiments(): JSX.Element {
const { filteredExperiments, experimentsLoading, tab, getExperimentStatus, searchTerm } =
useValues(experimentsLogic)
const { filteredExperiments, experimentsLoading, tab, searchTerm } = useValues(experimentsLogic)
const { setExperimentsTab, deleteExperiment, setSearchStatus, setSearchTerm } = useActions(experimentsLogic)
const { hasAvailableFeature } = useValues(userLogic)

View File

@ -9,6 +9,15 @@ import { userLogic } from 'scenes/userLogic'
import { subscriptions } from 'kea-subscriptions'
import { loaders } from 'kea-loaders'
export function getExperimentStatus(experiment: Experiment): ExperimentStatus {
if (!experiment.start_date) {
return ExperimentStatus.Draft
} else if (!experiment.end_date) {
return ExperimentStatus.Running
}
return ExperimentStatus.Complete
}
export const experimentsLogic = kea<experimentsLogicType>([
path(['scenes', 'experiments', 'experimentsLogic']),
connect({ values: [teamLogic, ['currentTeamId'], userLogic, ['user', 'hasAvailableFeature']] }),
@ -57,27 +66,9 @@ export const experimentsLogic = kea<experimentsLogicType>([
],
})),
selectors(({ values }) => ({
getExperimentStatus: [
(s) => [s.experiments],
() =>
(experiment: Experiment): ExperimentStatus => {
if (!experiment.start_date) {
return ExperimentStatus.Draft
} else if (!experiment.end_date) {
return ExperimentStatus.Running
}
return ExperimentStatus.Complete
},
],
filteredExperiments: [
(selectors) => [
selectors.experiments,
selectors.searchTerm,
selectors.searchStatus,
selectors.tab,
selectors.getExperimentStatus,
],
(experiments, searchTerm, searchStatus, tab, getExperimentStatus) => {
(selectors) => [selectors.experiments, selectors.searchTerm, selectors.searchStatus, selectors.tab],
(experiments, searchTerm, searchStatus, tab) => {
let filteredExperiments: Experiment[] = experiments
if (tab === ExperimentsTabs.Archived) {
@ -108,8 +99,8 @@ export const experimentsLogic = kea<experimentsLogicType>([
},
],
hasExperimentAvailableFeature: [
() => [],
(): boolean => values.hasAvailableFeature(AvailableFeature.EXPERIMENTATION),
(s) => [s.hasAvailableFeature],
(hasAvailableFeature): boolean => hasAvailableFeature(AvailableFeature.EXPERIMENTATION),
],
})),
events(({ actions }) => ({

View File

@ -1,5 +1,5 @@
import { FilterType, InsightLogicProps, QueryEditorFilterProps } from '~/types'
import { LemonButton, LemonLabel, LemonSwitch } from '@posthog/lemon-ui'
import { LemonButton, LemonLabel, LemonSwitch, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { AVAILABLE_SAMPLING_PERCENTAGES, samplingFilterLogic } from './samplingFilterLogic'
import posthog from 'posthog-js'
@ -50,7 +50,7 @@ export function SamplingFilter({
info={infoTooltipContent || DEFAULT_SAMPLING_INFO_TOOLTIP_CONTENT}
infoLink="https://posthog.com/manual/sampling"
>
Sampling (Beta)
Sampling<LemonTag type="warning">BETA</LemonTag>
</LemonLabel>
<LemonSwitch
className="m-2"

View File

@ -4,7 +4,7 @@ import { Card } from 'antd'
import { insightLogic } from 'scenes/insights/insightLogic'
import { funnelLogic } from 'scenes/funnels/funnelLogic'
import { IconFeedbackWarning } from 'lib/lemon-ui/icons'
import { IconFeedback } from 'lib/lemon-ui/icons'
import { CloseOutlined } from '@ant-design/icons'
import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic'
@ -40,8 +40,8 @@ const FunnelCorrelationSkewWarningComponent = ({
return (
<Card className="skew-warning">
<h4>
<IconFeedbackWarning style={{ fontSize: 24, marginRight: 4, color: 'var(--warning)' }} /> Adjust your
funnel definition to improve correlation analysis
<IconFeedback style={{ fontSize: 24, marginRight: 4, color: 'var(--warning)' }} /> Adjust your funnel
definition to improve correlation analysis
<CloseOutlined className="close-button" onClick={hideSkewWarning} />
</h4>
<div>

View File

@ -240,7 +240,7 @@
"jest-canvas-mock": "^2.4.0",
"jest-environment-jsdom": "^29.3.1",
"jest-image-snapshot": "^6.1.0",
"kea-typegen": "^3.1.5",
"kea-typegen": "^3.1.6",
"less": "^3.12.2",
"less-loader": "^7.0.2",
"lint-staged": "~10.2.13",

View File

@ -522,8 +522,8 @@ devDependencies:
specifier: ^6.1.0
version: 6.1.0(jest@29.3.1)
kea-typegen:
specifier: ^3.1.5
version: 3.1.5(typescript@4.9.5)
specifier: ^3.1.6
version: 3.1.6(typescript@4.9.5)
less:
specifier: ^3.12.2
version: 3.13.1
@ -13394,8 +13394,8 @@ packages:
kea-waitfor: 0.2.1(kea@3.1.5)
dev: false
/kea-typegen@3.1.5(typescript@4.9.5):
resolution: {integrity: sha512-ocQUbGcUoAc3C23wgSYjMkwXkBvD3IKwbTgtBfP4K3iz8R02BGxwbiqpxIrtCGVDImx0p+L2UDz8YX0ADkolAQ==}
/kea-typegen@3.1.6(typescript@4.9.5):
resolution: {integrity: sha512-Rbdr2+oW4N7S4WsTpMv8DdazpPOCSeKa4xbpMfkA2Qy6lNmr1SLMFgx43Ei/lDVLO52H04C5/qISIdqjQ6hpvQ==}
hasBin: true
peerDependencies:
typescript: '>=4.5.3'