fix(err): make assignee select consistent (#31185)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -1,20 +0,0 @@
|
||||
import { useValues } from 'kea'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
|
||||
|
||||
import { AssigneeDisplayType, assigneeSelectLogic } from './assigneeSelectLogic'
|
||||
|
||||
export const AssigneeDisplay = ({
|
||||
children,
|
||||
assignee,
|
||||
}: {
|
||||
children: (props: { displayAssignee: AssigneeDisplayType }) => React.ReactElement
|
||||
assignee: ErrorTrackingIssue['assignee']
|
||||
}): React.ReactElement => {
|
||||
const { computeAssignee } = useValues(assigneeSelectLogic)
|
||||
|
||||
const displayAssignee = useMemo(() => computeAssignee(assignee), [assignee, computeAssignee])
|
||||
|
||||
return children({ displayAssignee })
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { IconPlusSmall, IconX } from '@posthog/icons'
|
||||
import { LemonButton, LemonButtonProps, LemonDropdown, LemonInput } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { ErrorTrackingIssue, ErrorTrackingIssueAssignee } from '~/queries/schema/schema-general'
|
||||
|
||||
import { AssigneeDisplay } from './AssigneeDisplay'
|
||||
import { AssigneeDisplayType, assigneeSelectLogic } from './assigneeSelectLogic'
|
||||
|
||||
export const AssigneeSelect = ({
|
||||
assignee,
|
||||
onChange,
|
||||
showName = false,
|
||||
showIcon = true,
|
||||
unassignedLabel = 'Unassigned',
|
||||
...buttonProps
|
||||
}: {
|
||||
assignee: ErrorTrackingIssue['assignee']
|
||||
onChange: (assignee: ErrorTrackingIssue['assignee']) => void
|
||||
showName?: boolean
|
||||
showIcon?: boolean
|
||||
unassignedLabel?: string
|
||||
} & Partial<Pick<LemonButtonProps, 'type' | 'size'>>): JSX.Element => {
|
||||
const { search, groupOptions, memberOptions, userGroupsLoading, membersLoading } = useValues(assigneeSelectLogic)
|
||||
const { setSearch, ensureAssigneeTypesLoaded } = useActions(assigneeSelectLogic)
|
||||
const [showPopover, setShowPopover] = useState(false)
|
||||
|
||||
const _onChange = (value: ErrorTrackingIssue['assignee']): void => {
|
||||
setSearch('')
|
||||
setShowPopover(false)
|
||||
onChange(value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
ensureAssigneeTypesLoaded()
|
||||
}, [ensureAssigneeTypesLoaded])
|
||||
|
||||
return (
|
||||
<LemonDropdown
|
||||
closeOnClickInside={false}
|
||||
visible={showPopover}
|
||||
matchWidth={false}
|
||||
onVisibilityChange={(visible) => setShowPopover(visible)}
|
||||
overlay={
|
||||
<div className="max-w-100 deprecated-space-y-2 overflow-hidden">
|
||||
<LemonInput
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
fullWidth
|
||||
/>
|
||||
<ul className="deprecated-space-y-2">
|
||||
{assignee && (
|
||||
<li>
|
||||
<LemonButton
|
||||
fullWidth
|
||||
role="menuitem"
|
||||
size="small"
|
||||
icon={<IconX />}
|
||||
onClick={() => _onChange(null)}
|
||||
>
|
||||
Remove assignee
|
||||
</LemonButton>
|
||||
</li>
|
||||
)}
|
||||
|
||||
<Section
|
||||
title="Groups"
|
||||
loading={userGroupsLoading}
|
||||
search={!!search}
|
||||
type="user_group"
|
||||
items={groupOptions}
|
||||
onSelect={_onChange}
|
||||
activeId={assignee?.id}
|
||||
emptyState={
|
||||
<LemonButton
|
||||
fullWidth
|
||||
size="small"
|
||||
icon={<IconPlusSmall />}
|
||||
to={urls.settings('environment-error-tracking', 'user-groups')}
|
||||
>
|
||||
<div className="text-secondary">Create user group</div>
|
||||
</LemonButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<Section
|
||||
title="Users"
|
||||
loading={membersLoading}
|
||||
search={!!search}
|
||||
type="user"
|
||||
items={memberOptions}
|
||||
onSelect={_onChange}
|
||||
activeId={assignee?.id}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<AssigneeDisplay assignee={assignee}>
|
||||
{({ displayAssignee }) => (
|
||||
<LemonButton
|
||||
tooltip={displayAssignee.displayName}
|
||||
icon={showIcon ? displayAssignee.icon : null}
|
||||
{...buttonProps}
|
||||
>
|
||||
{showName ? (
|
||||
<span className="pl-1">
|
||||
{displayAssignee.id === 'unassigned'
|
||||
? unassignedLabel
|
||||
: displayAssignee.displayName}
|
||||
</span>
|
||||
) : null}
|
||||
</LemonButton>
|
||||
)}
|
||||
</AssigneeDisplay>
|
||||
</div>
|
||||
</LemonDropdown>
|
||||
)
|
||||
}
|
||||
|
||||
const Section = ({
|
||||
loading,
|
||||
search,
|
||||
type,
|
||||
items,
|
||||
onSelect,
|
||||
activeId,
|
||||
emptyState,
|
||||
title,
|
||||
}: {
|
||||
title: string
|
||||
loading: boolean
|
||||
search: boolean
|
||||
type: ErrorTrackingIssueAssignee['type']
|
||||
items: AssigneeDisplayType[]
|
||||
onSelect: (value: ErrorTrackingIssue['assignee']) => void
|
||||
activeId?: string | number
|
||||
emptyState?: JSX.Element
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<li>
|
||||
<section className="deprecated-space-y-px">
|
||||
<h5 className="mx-2 my-0.5">{title}</h5>
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<LemonButton
|
||||
fullWidth
|
||||
role="menuitem"
|
||||
size="small"
|
||||
icon={item.icon}
|
||||
onClick={() => onSelect(activeId === item.id ? null : { type, id: item.id })}
|
||||
active={activeId === item.id}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2 flex-1">
|
||||
<span>{item.displayName}</span>
|
||||
</span>
|
||||
</LemonButton>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{loading ? (
|
||||
<div className="p-2 text-secondary italic truncate border-t">Loading...</div>
|
||||
) : items.length === 0 ? (
|
||||
search ? (
|
||||
<div className="p-2 text-secondary italic truncate border-t">
|
||||
<span>No matches</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t pt-1">{emptyState}</div>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import './ErrorTracking.scss'
|
||||
|
||||
import { LemonCard } from '@posthog/lemon-ui'
|
||||
import { LemonButton, LemonCard } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { PageHeader } from 'lib/components/PageHeader'
|
||||
import { useEffect } from 'react'
|
||||
@@ -8,7 +8,8 @@ import { SceneExport } from 'scenes/sceneTypes'
|
||||
|
||||
import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
|
||||
|
||||
import { AssigneeSelect } from './AssigneeSelect'
|
||||
import { AssigneeIconDisplay, AssigneeLabelDisplay } from './components/Assignee/AssigneeDisplay'
|
||||
import { AssigneeSelect } from './components/Assignee/AssigneeSelect'
|
||||
import { IssueCard } from './components/IssueCard'
|
||||
import { DateRangeFilter, FilterGroup, InternalAccountsFilter } from './ErrorTrackingFilters'
|
||||
import { errorTrackingIssueSceneLogic } from './errorTrackingIssueSceneLogic'
|
||||
@@ -49,12 +50,18 @@ export function ErrorTrackingIssueScene(): JSX.Element {
|
||||
buttons={
|
||||
<div className="flex gap-x-2">
|
||||
{!issueLoading && issue?.status == 'active' && (
|
||||
<AssigneeSelect
|
||||
assignee={issue?.assignee}
|
||||
onChange={updateAssignee}
|
||||
type="secondary"
|
||||
showName
|
||||
/>
|
||||
<AssigneeSelect assignee={issue?.assignee} onChange={updateAssignee}>
|
||||
{(displayAssignee) => {
|
||||
return (
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
icon={<AssigneeIconDisplay assignee={displayAssignee} />}
|
||||
>
|
||||
<AssigneeLabelDisplay assignee={displayAssignee} placeholder="Unassigned" />
|
||||
</LemonButton>
|
||||
)
|
||||
}}
|
||||
</AssigneeSelect>
|
||||
)}
|
||||
{!issueLoading && (
|
||||
<GenericSelect
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { LemonSelect } from '@posthog/lemon-ui'
|
||||
import { LemonButton, LemonSelect } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
|
||||
import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
|
||||
|
||||
import { AssigneeSelect } from './AssigneeSelect'
|
||||
import { AssigneeLabelDisplay } from './components/Assignee/AssigneeDisplay'
|
||||
import { AssigneeSelect } from './components/Assignee/AssigneeSelect'
|
||||
import { errorTrackingLogic } from './errorTrackingLogic'
|
||||
import { errorTrackingSceneLogic } from './errorTrackingSceneLogic'
|
||||
import { BulkActions } from './issue/BulkActions'
|
||||
@@ -90,15 +91,15 @@ export const ErrorTrackingListOptions = (): JSX.Element => {
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Assigned to:</span>
|
||||
<AssigneeSelect
|
||||
showName
|
||||
showIcon={false}
|
||||
assignee={assignee}
|
||||
onChange={(assignee) => setAssignee(assignee)}
|
||||
unassignedLabel="Any user"
|
||||
type="secondary"
|
||||
size="small"
|
||||
/>
|
||||
<AssigneeSelect assignee={assignee} onChange={(assignee) => setAssignee(assignee)}>
|
||||
{(displayAssignee) => {
|
||||
return (
|
||||
<LemonButton type="secondary" size="small">
|
||||
<AssigneeLabelDisplay assignee={displayAssignee} placeholder="Any user" />
|
||||
</LemonButton>
|
||||
)
|
||||
}}
|
||||
</AssigneeSelect>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconGear } from '@posthog/icons'
|
||||
import { IconChevronDown, IconGear } from '@posthog/icons'
|
||||
import { LemonBanner, LemonButton, LemonCheckbox, LemonDivider, LemonSkeleton, Link, Tooltip } from '@posthog/lemon-ui'
|
||||
import { BindLogic, useActions, useValues } from 'kea'
|
||||
import { PageHeader } from 'lib/components/PageHeader'
|
||||
@@ -16,7 +16,8 @@ import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
|
||||
import { QueryContext, QueryContextColumnComponent, QueryContextColumnTitleComponent } from '~/queries/types'
|
||||
import { InsightLogicProps } from '~/types'
|
||||
|
||||
import { AssigneeSelect } from './AssigneeSelect'
|
||||
import { AssigneeIconDisplay, AssigneeLabelDisplay } from './components/Assignee/AssigneeDisplay'
|
||||
import { AssigneeSelect } from './components/Assignee/AssigneeSelect'
|
||||
import { errorTrackingDataNodeLogic } from './errorTrackingDataNodeLogic'
|
||||
import { DateRangeFilter, ErrorTrackingFilters, FilterGroup, InternalAccountsFilter } from './ErrorTrackingFilters'
|
||||
import { errorTrackingIssueSceneLogic } from './errorTrackingIssueSceneLogic'
|
||||
@@ -164,12 +165,26 @@ const CustomGroupTitleColumn: QueryContextColumnComponent = (props) => {
|
||||
)}
|
||||
<span>|</span>
|
||||
<AssigneeSelect
|
||||
showName={true}
|
||||
showIcon={false}
|
||||
assignee={record.assignee}
|
||||
onChange={(assignee) => assignIssue(record.id, assignee)}
|
||||
size="xsmall"
|
||||
/>
|
||||
>
|
||||
{(anyAssignee) => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center hover:bg-fill-button-tertiary-hover p-[0.1rem] rounded cursor-pointer"
|
||||
role="button"
|
||||
>
|
||||
<AssigneeIconDisplay assignee={anyAssignee} size="xsmall" />
|
||||
<AssigneeLabelDisplay
|
||||
assignee={anyAssignee}
|
||||
className="ml-1 text-xs text-secondary"
|
||||
size="xsmall"
|
||||
/>
|
||||
<IconChevronDown />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</AssigneeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Meta } from '@storybook/react'
|
||||
|
||||
import { AssigneeDisplay, AssigneeIconDisplayProps } from './AssigneeDisplay'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'ErrorTracking/AssigneeDisplay',
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
viewMode: 'story',
|
||||
},
|
||||
args: {
|
||||
sizes: ['xsmall', 'small', 'medium', 'large'],
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type SizedComponentProps = {
|
||||
sizes: AssigneeIconDisplayProps['size'][]
|
||||
}
|
||||
|
||||
export const UnassignedDisplays = ({ sizes }: SizedComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sizes.map((size) => (
|
||||
<AssigneeDisplay key={size} size={size} assignee={null} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const UserDisplays = ({ sizes }: SizedComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sizes.map((size) => (
|
||||
<AssigneeDisplay
|
||||
key={size}
|
||||
size={size}
|
||||
assignee={{
|
||||
id: 1,
|
||||
type: 'user',
|
||||
user: {
|
||||
id: 1,
|
||||
uuid: '123e4567-e89b-12d3-a456-426614174000',
|
||||
distinct_id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@gmail.com',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const GroupDisplays = ({ sizes }: SizedComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sizes.map((size) => (
|
||||
<AssigneeDisplay
|
||||
key={size}
|
||||
size={size}
|
||||
assignee={{
|
||||
id: '123',
|
||||
type: 'group',
|
||||
group: {
|
||||
id: '123',
|
||||
name: 'Group Name',
|
||||
members: [],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AllDisplays = ({ sizes }: SizedComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div className="flex gap-4 justify-start items-start">
|
||||
<UnassignedDisplays sizes={sizes} />
|
||||
<UserDisplays sizes={sizes} />
|
||||
<GroupDisplays sizes={sizes} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { IconPerson } from '@posthog/icons'
|
||||
import { ProfilePicture } from '@posthog/lemon-ui'
|
||||
import { useValues } from 'kea'
|
||||
import { fullName, UnexpectedNeverError } from 'lib/utils'
|
||||
import { cn } from 'lib/utils/css-classes'
|
||||
import React, { useMemo } from 'react'
|
||||
import { match } from 'ts-pattern'
|
||||
|
||||
import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
|
||||
|
||||
import { Assignee, assigneeSelectLogic } from './assigneeSelectLogic'
|
||||
|
||||
export interface AssigneeAnyDisplayProps {
|
||||
assignee: Assignee
|
||||
}
|
||||
|
||||
export interface AssigneeResolverProps {
|
||||
children: (props: { assignee: Assignee }) => React.ReactElement
|
||||
assignee: ErrorTrackingIssue['assignee']
|
||||
}
|
||||
|
||||
export const AssigneeResolver = ({ children, assignee }: AssigneeResolverProps): React.ReactElement => {
|
||||
const { resolveAssignee } = useValues(assigneeSelectLogic)
|
||||
const resolvedAssignee = useMemo(() => resolveAssignee(assignee), [assignee, resolveAssignee])
|
||||
return children({ assignee: resolvedAssignee })
|
||||
}
|
||||
|
||||
export interface AssigneeBaseDisplayProps {
|
||||
assignee: Assignee
|
||||
size?: 'xsmall' | 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
export interface AssigneeIconDisplayProps extends AssigneeBaseDisplayProps {}
|
||||
|
||||
function getIconClassname(size: 'xsmall' | 'small' | 'medium' | 'large' = 'medium'): string {
|
||||
switch (size) {
|
||||
case 'xsmall':
|
||||
return 'text-[0.6rem] h-3 w-3'
|
||||
case 'small':
|
||||
return 'text-[0.75rem] h-4 w-4'
|
||||
case 'medium':
|
||||
return 'text-[0.85rem] h-5 w-5'
|
||||
case 'large':
|
||||
return 'text-[1rem] h-6 w-6'
|
||||
default:
|
||||
throw new UnexpectedNeverError(size)
|
||||
}
|
||||
}
|
||||
|
||||
export const AssigneeIconDisplay = ({ assignee, size }: AssigneeIconDisplayProps): JSX.Element => {
|
||||
return match(assignee)
|
||||
.with({ type: 'group' }, ({ group }) => (
|
||||
// The ideal way would be to use a Lettermark component here
|
||||
// but there is no way to make it consistent with ProfilePicture at the moment
|
||||
// TODO: Make sure the size prop are the same between ProfilePicture and Lettermark
|
||||
<ProfilePicture
|
||||
user={{ first_name: group.name, last_name: undefined, email: undefined }}
|
||||
className={getIconClassname(size)}
|
||||
/>
|
||||
))
|
||||
.with({ type: 'user' }, ({ user }) => <ProfilePicture user={user} className={getIconClassname(size)} />)
|
||||
.otherwise(() => (
|
||||
<IconPerson
|
||||
className={cn(
|
||||
'rounded-full border border-dashed border-secondary text-secondary flex items-center justify-center p-0.5',
|
||||
getIconClassname(size)
|
||||
)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
export interface AssigneeLabelDisplayProps extends AssigneeBaseDisplayProps {
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const AssigneeLabelDisplay = ({
|
||||
assignee,
|
||||
className,
|
||||
size,
|
||||
placeholder,
|
||||
}: AssigneeLabelDisplayProps): JSX.Element => {
|
||||
return (
|
||||
<span
|
||||
className={cn(className, {
|
||||
'text-xs': size === 'xsmall',
|
||||
'text-sm': size === 'small',
|
||||
'text-base': size === 'medium',
|
||||
'text-lg': size === 'large',
|
||||
})}
|
||||
>
|
||||
{match(assignee)
|
||||
.with({ type: 'group' }, ({ group }) => group.name)
|
||||
.with({ type: 'user' }, ({ user }) => fullName(user))
|
||||
.otherwise(() => placeholder || 'Unassigned')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface AssigneeDisplayProps
|
||||
extends AssigneeBaseDisplayProps,
|
||||
Omit<AssigneeLabelDisplayProps, 'className'>,
|
||||
AssigneeIconDisplayProps {
|
||||
className?: string
|
||||
labelClassname?: string
|
||||
}
|
||||
|
||||
export const AssigneeDisplay = ({
|
||||
assignee,
|
||||
placeholder,
|
||||
className,
|
||||
labelClassname,
|
||||
size,
|
||||
}: AssigneeDisplayProps): JSX.Element => {
|
||||
return (
|
||||
<div className={cn('flex justify-start items-center gap-1', className)}>
|
||||
<AssigneeIconDisplay assignee={assignee} size={size} />
|
||||
<AssigneeLabelDisplay
|
||||
className={labelClassname}
|
||||
size={size}
|
||||
assignee={assignee}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { IconPlusSmall, IconX } from '@posthog/icons'
|
||||
import { LemonButton, LemonInput } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { ErrorTrackingIssue, ErrorTrackingIssueAssignee } from '~/queries/schema/schema-general'
|
||||
|
||||
import { AssigneeIconDisplay, AssigneeLabelDisplay } from './AssigneeDisplay'
|
||||
import { Assignee, assigneeSelectLogic } from './assigneeSelectLogic'
|
||||
|
||||
export interface AssigneeDropdownProps {
|
||||
assignee: ErrorTrackingIssueAssignee | null
|
||||
onChange: (assignee: ErrorTrackingIssueAssignee | null) => void
|
||||
}
|
||||
|
||||
export function AssigneeDropdown({ assignee, onChange }: AssigneeDropdownProps): JSX.Element {
|
||||
const { search, filteredGroups, filteredMembers, userGroupsLoading, membersLoading } =
|
||||
useValues(assigneeSelectLogic)
|
||||
const { setSearch } = useActions(assigneeSelectLogic)
|
||||
return (
|
||||
<div className="max-w-100 deprecated-space-y-2 overflow-hidden">
|
||||
<LemonInput type="search" placeholder="Search" autoFocus value={search} onChange={setSearch} fullWidth />
|
||||
<ul className="deprecated-space-y-2">
|
||||
{assignee && (
|
||||
<li>
|
||||
<LemonButton
|
||||
fullWidth
|
||||
role="menuitem"
|
||||
size="small"
|
||||
icon={<IconX />}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
Remove assignee
|
||||
</LemonButton>
|
||||
</li>
|
||||
)}
|
||||
|
||||
<Section
|
||||
title="Groups"
|
||||
loading={userGroupsLoading}
|
||||
search={!!search}
|
||||
type="user_group"
|
||||
items={filteredGroups.map((group) => ({
|
||||
id: group.id,
|
||||
type: 'group',
|
||||
group: group,
|
||||
}))}
|
||||
onSelect={onChange}
|
||||
activeId={assignee?.id}
|
||||
emptyState={
|
||||
<LemonButton
|
||||
fullWidth
|
||||
size="small"
|
||||
icon={<IconPlusSmall />}
|
||||
to={urls.settings('environment-error-tracking', 'user-groups')}
|
||||
>
|
||||
<div className="text-secondary">Create user group</div>
|
||||
</LemonButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<Section
|
||||
title="Users"
|
||||
loading={membersLoading}
|
||||
search={!!search}
|
||||
type="user"
|
||||
items={filteredMembers.map((member) => ({
|
||||
id: member.user.id,
|
||||
type: 'user',
|
||||
user: member.user,
|
||||
}))}
|
||||
onSelect={onChange}
|
||||
activeId={assignee?.id}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Section = ({
|
||||
loading,
|
||||
search,
|
||||
type,
|
||||
items,
|
||||
onSelect,
|
||||
activeId,
|
||||
emptyState,
|
||||
title,
|
||||
}: {
|
||||
title: string
|
||||
loading: boolean
|
||||
search: boolean
|
||||
type: ErrorTrackingIssueAssignee['type']
|
||||
items: Assignee[]
|
||||
onSelect: (value: ErrorTrackingIssue['assignee']) => void
|
||||
activeId?: string | number
|
||||
emptyState?: JSX.Element
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<li>
|
||||
<section className="deprecated-space-y-px">
|
||||
<h5 className="mx-2 my-0.5">{title}</h5>
|
||||
{items.map((item) => (
|
||||
<li key={item?.id || 'unassigned'}>
|
||||
<LemonButton
|
||||
fullWidth
|
||||
role="menuitem"
|
||||
size="small"
|
||||
icon={<AssigneeIconDisplay assignee={item} />}
|
||||
onClick={() => item?.id && onSelect(activeId === item.id ? null : { type, id: item.id })}
|
||||
active={activeId === item?.id}
|
||||
>
|
||||
<AssigneeLabelDisplay assignee={item} />
|
||||
</LemonButton>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{loading ? (
|
||||
<div className="p-2 text-secondary italic truncate border-t">Loading...</div>
|
||||
) : items.length === 0 ? (
|
||||
search ? (
|
||||
<div className="p-2 text-secondary italic truncate border-t">
|
||||
<span>No matches</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t pt-1">{emptyState}</div>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { LemonDropdown } from '@posthog/lemon-ui'
|
||||
import { useActions } from 'kea'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
|
||||
|
||||
import { AssigneeResolver } from './AssigneeDisplay'
|
||||
import { AssigneeDropdown } from './AssigneeDropdown'
|
||||
import { Assignee, assigneeSelectLogic } from './assigneeSelectLogic'
|
||||
|
||||
export const AssigneeSelect = ({
|
||||
assignee,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
assignee: ErrorTrackingIssue['assignee']
|
||||
onChange: (assignee: ErrorTrackingIssue['assignee']) => void
|
||||
children: (assignee: Assignee) => JSX.Element
|
||||
}): JSX.Element => {
|
||||
const { setSearch, ensureAssigneeTypesLoaded } = useActions(assigneeSelectLogic)
|
||||
const [showPopover, setShowPopover] = useState(false)
|
||||
|
||||
const _onChange = (value: ErrorTrackingIssue['assignee']): void => {
|
||||
setSearch('')
|
||||
setShowPopover(false)
|
||||
onChange(value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
ensureAssigneeTypesLoaded()
|
||||
}, [ensureAssigneeTypesLoaded])
|
||||
|
||||
return (
|
||||
<LemonDropdown
|
||||
closeOnClickInside={false}
|
||||
visible={showPopover}
|
||||
matchWidth={false}
|
||||
onVisibilityChange={(visible) => setShowPopover(visible)}
|
||||
overlay={<AssigneeDropdown assignee={assignee} onChange={_onChange} />}
|
||||
>
|
||||
<div>
|
||||
<AssigneeResolver assignee={assignee}>
|
||||
{({ assignee: resolvedAssignee }) => children(resolvedAssignee)}
|
||||
</AssigneeResolver>
|
||||
</div>
|
||||
</LemonDropdown>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { IconPerson } from '@posthog/icons'
|
||||
import { Lettermark, ProfilePicture } from '@posthog/lemon-ui'
|
||||
import { actions, connect, kea, listeners, path, props, reducers, selectors } from 'kea'
|
||||
import { fullName } from 'lib/utils'
|
||||
import { membersLogic } from 'scenes/organization/membersLogic'
|
||||
import { userGroupsLogic } from 'scenes/settings/environment/userGroupsLogic'
|
||||
|
||||
import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
|
||||
import { OrganizationMemberType, UserGroup } from '~/types'
|
||||
import type { OrganizationMemberType, UserGroup } from '~/types'
|
||||
|
||||
import type { assigneeSelectLogicType } from './assigneeSelectLogicType'
|
||||
|
||||
@@ -14,26 +11,20 @@ export type ErrorTrackingAssigneeSelectProps = {
|
||||
assignee: ErrorTrackingIssue['assignee']
|
||||
}
|
||||
|
||||
export type AssigneeDisplayType = { id: string | number; icon: JSX.Element; displayName?: string }
|
||||
|
||||
const groupDisplay = (group: UserGroup, index: number): AssigneeDisplayType => ({
|
||||
id: group.id,
|
||||
displayName: group.name,
|
||||
icon: <Lettermark name={group.name} index={index} rounded />,
|
||||
})
|
||||
|
||||
const userDisplay = (member: OrganizationMemberType): AssigneeDisplayType => ({
|
||||
id: member.user.id,
|
||||
displayName: fullName(member.user),
|
||||
icon: <ProfilePicture size="md" user={member.user} />,
|
||||
})
|
||||
|
||||
const unassignedDisplay: AssigneeDisplayType = {
|
||||
id: 'unassigned',
|
||||
displayName: 'Unassigned',
|
||||
icon: <IconPerson className="rounded-full border border-dashed border-muted text-secondary p-0.5" />,
|
||||
export type UserAssignee = {
|
||||
id: number
|
||||
type: 'user'
|
||||
user: OrganizationMemberType['user']
|
||||
}
|
||||
|
||||
export type GroupAssignee = {
|
||||
id: string
|
||||
type: 'group'
|
||||
group: UserGroup
|
||||
}
|
||||
|
||||
export type Assignee = UserAssignee | GroupAssignee | null
|
||||
|
||||
export const assigneeSelectLogic = kea<assigneeSelectLogicType>([
|
||||
path(['scenes', 'error-tracking', 'assigneeSelectLogic']),
|
||||
props({} as ErrorTrackingAssigneeSelectProps),
|
||||
@@ -79,24 +70,33 @@ export const assigneeSelectLogic = kea<assigneeSelectLogicType>([
|
||||
(membersLoading, userGroupsLoading): boolean => membersLoading || userGroupsLoading,
|
||||
],
|
||||
|
||||
groupOptions: [(s) => [s.filteredGroups], (groups): AssigneeDisplayType[] => groups.map(groupDisplay)],
|
||||
memberOptions: [(s) => [s.filteredMembers], (members): AssigneeDisplayType[] => members.map(userDisplay)],
|
||||
|
||||
computeAssignee: [
|
||||
resolveAssignee: [
|
||||
(s) => [s.userGroups, s.meFirstMembers],
|
||||
(groups, members): ((assignee: ErrorTrackingIssue['assignee']) => AssigneeDisplayType) => {
|
||||
(groups, members): ((assignee: ErrorTrackingIssue['assignee']) => Assignee) => {
|
||||
return (assignee: ErrorTrackingIssue['assignee']) => {
|
||||
if (assignee) {
|
||||
if (assignee.type === 'user_group') {
|
||||
const assignedGroup = groups.find((group) => group.id === assignee.id)
|
||||
return assignedGroup ? groupDisplay(assignedGroup, 0) : unassignedDisplay
|
||||
return assignedGroup
|
||||
? {
|
||||
id: assignedGroup.id,
|
||||
type: 'group',
|
||||
group: assignedGroup,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const assignedMember = members.find((member) => member.user.id === assignee.id)
|
||||
return assignedMember ? userDisplay(assignedMember) : unassignedDisplay
|
||||
return assignedMember
|
||||
? {
|
||||
id: assignedMember.user.id,
|
||||
type: 'user',
|
||||
user: assignedMember.user,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
return unassignedDisplay
|
||||
return null
|
||||
}
|
||||
},
|
||||
],
|
||||
@@ -17,8 +17,8 @@ import { urls } from 'scenes/urls'
|
||||
import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
|
||||
import { ActivityScope } from '~/types'
|
||||
|
||||
import { AssigneeDisplay } from './AssigneeDisplay'
|
||||
import { assigneeSelectLogic } from './assigneeSelectLogic'
|
||||
import { AssigneeIconDisplay, AssigneeLabelDisplay, AssigneeResolver } from './components/Assignee/AssigneeDisplay'
|
||||
import { assigneeSelectLogic } from './components/Assignee/assigneeSelectLogic'
|
||||
|
||||
type ErrorTrackingIssueAssignee = Exclude<ErrorTrackingIssue['assignee'], null>
|
||||
|
||||
@@ -30,14 +30,14 @@ function AssigneeRenderer({ assignee }: { assignee: ErrorTrackingIssueAssignee }
|
||||
}, [ensureAssigneeTypesLoaded])
|
||||
|
||||
return (
|
||||
<AssigneeDisplay assignee={assignee}>
|
||||
{({ displayAssignee }) => (
|
||||
<AssigneeResolver assignee={assignee}>
|
||||
{({ assignee }) => (
|
||||
<span className="flex gap-x-0.5">
|
||||
{displayAssignee.icon}
|
||||
<span>{displayAssignee.displayName}</span>
|
||||
<AssigneeIconDisplay assignee={assignee} />
|
||||
<AssigneeLabelDisplay assignee={assignee} />
|
||||
</span>
|
||||
)}
|
||||
</AssigneeDisplay>
|
||||
</AssigneeResolver>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useActions, useValues } from 'kea'
|
||||
|
||||
import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
|
||||
|
||||
import { AssigneeSelect } from '../AssigneeSelect'
|
||||
import { AssigneeLabelDisplay } from '../components/Assignee/AssigneeDisplay'
|
||||
import { AssigneeSelect } from '../components/Assignee/AssigneeSelect'
|
||||
import { errorTrackingDataNodeLogic } from '../errorTrackingDataNodeLogic'
|
||||
import { errorTrackingSceneLogic } from '../errorTrackingSceneLogic'
|
||||
import { GenericSelect } from './GenericSelect'
|
||||
@@ -93,15 +94,15 @@ export function BulkActions(): JSX.Element {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AssigneeSelect
|
||||
type="secondary"
|
||||
size="small"
|
||||
showName
|
||||
showIcon={false}
|
||||
unassignedLabel="Assign"
|
||||
assignee={null}
|
||||
onChange={(assignee) => assignIssues(selectedIssueIds, assignee)}
|
||||
/>
|
||||
<AssigneeSelect assignee={null} onChange={(assignee) => assignIssues(selectedIssueIds, assignee)}>
|
||||
{(displayAssignee) => {
|
||||
return (
|
||||
<LemonButton type="secondary" size="small">
|
||||
<AssigneeLabelDisplay assignee={displayAssignee} placeholder="Assign" />
|
||||
</LemonButton>
|
||||
)
|
||||
}}
|
||||
</AssigneeSelect>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
|
||||