analytics/assets/js/dashboard/segments/segment-modals.tsx

605 lines
16 KiB
TypeScript

import React, { ReactNode, useCallback, useState } from 'react'
import ModalWithRouting from '../stats/modals/modal'
import {
canSeeSegmentDetails,
isListableSegment,
isSegmentFilter,
SavedSegment,
SEGMENT_TYPE_LABELS,
SegmentData,
SegmentType
} from '../filtering/segments'
import { useQueryContext } from '../query-context'
import { AppNavigationLink } from '../navigation/use-app-navigate'
import { cleanLabels } from '../util/filters'
import { plainFilterText, styledFilterText } from '../util/filter-text'
import { rootRoute } from '../router'
import { FilterPillsList } from '../nav-menu/filter-pills-list'
import classNames from 'classnames'
import { SegmentAuthorship } from './segment-authorship'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { MutationStatus } from '@tanstack/react-query'
import { ApiError } from '../api'
import { ErrorPanel } from '../components/error-panel'
import { useSegmentsContext } from '../filtering/segments-context'
import { useSiteContext } from '../site-context'
import { Role, UserContextValue, useUserContext } from '../user-context'
import { removeFilterButtonClassname } from '../components/remove-filter-button'
interface ApiRequestProps {
status: MutationStatus
error?: unknown
reset: () => void
}
interface SegmentModalProps {
user: UserContextValue
siteSegmentsAvailable: boolean
onClose: () => void
namePlaceholder: string
}
const primaryNeutralButtonClassName = 'button !px-3'
const primaryNegativeButtonClassName = classNames(
'button !px-3.5',
'items-center !bg-red-500 dark:!bg-red-500 hover:!bg-red-600 dark:hover:!bg-red-700 whitespace-nowrap'
)
const secondaryButtonClassName = classNames(
'button !px-3.5',
'border !border-gray-300 dark:!border-gray-700 !bg-white dark:!bg-gray-700 !text-gray-800 dark:!text-gray-100 hover:!text-gray-900 hover:!shadow-sm dark:hover:!bg-gray-600 dark:hover:!text-white'
)
const SegmentActionModal = ({
children,
onClose
}: {
children: ReactNode
onClose: () => void
}) => {
return (
<ModalWithRouting
maxWidth="460px"
className="p-6 min-h-fit"
onClose={onClose}
>
<div className="mb-8 dark:text-gray-100">{children}</div>
</ModalWithRouting>
)
}
export const CreateSegmentModal = ({
segment,
onClose,
onSave,
siteSegmentsAvailable: siteSegmentsAvailable,
user,
namePlaceholder,
error,
reset,
status
}: SegmentModalProps &
ApiRequestProps & {
segment?: SavedSegment
onSave: (input: Pick<SavedSegment, 'name' | 'type'>) => void
}) => {
const defaultName = segment?.name
? `Copy of ${segment.name}`.slice(0, 255)
: ''
const [name, setName] = useState(defaultName)
const defaultType =
segment?.type === SegmentType.site &&
siteSegmentsAvailable &&
hasSiteSegmentPermission(user)
? SegmentType.site
: SegmentType.personal
const [type, setType] = useState<SegmentType>(defaultType)
const { disabled, disabledMessage, onSegmentTypeChange } =
useSegmentTypeDisabledState({
siteSegmentsAvailable,
user,
setType
})
return (
<SegmentActionModal onClose={onClose}>
<FormTitle>Create segment</FormTitle>
<SegmentNameInput
value={name}
onChange={setName}
namePlaceholder={namePlaceholder}
/>
<SegmentTypeSelector value={type} onChange={onSegmentTypeChange} />
{disabled && <SegmentTypeDisabledMessage message={disabledMessage} />}
<ButtonsRow>
<SaveSegmentButton
disabled={status === 'pending' || disabled}
onSave={() => {
const trimmedName = name.trim()
const saveableName = trimmedName.length
? trimmedName
: namePlaceholder
onSave({ name: saveableName, type })
}}
/>
<button className={secondaryButtonClassName} onClick={onClose}>
Cancel
</button>
</ButtonsRow>
{error !== null && (
<ErrorPanel
className="mt-4"
errorMessage={
error instanceof ApiError
? error.message
: 'Something went wrong creating segment'
}
onClose={reset}
/>
)}
</SegmentActionModal>
)
}
export const DeleteSegmentModal = ({
onClose,
onSave,
segment,
status,
error,
reset
}: {
onClose: () => void
onSave: (input: Pick<SavedSegment, 'id'>) => void
segment: SavedSegment & { segment_data?: SegmentData }
} & ApiRequestProps) => {
return (
<SegmentActionModal onClose={onClose}>
<FormTitle>
Delete {SEGMENT_TYPE_LABELS[segment.type].toLowerCase()}
<span className="break-all">{` "${segment.name}"?`}</span>
</FormTitle>
{!!segment.segment_data && (
<FiltersInSegment segment_data={segment.segment_data} />
)}
<ButtonsRow>
<button
className={primaryNegativeButtonClassName}
disabled={status === 'pending'}
onClick={
status === 'pending'
? () => {}
: () => {
onSave({ id: segment.id })
}
}
>
Delete
</button>
<button className={secondaryButtonClassName} onClick={onClose}>
Cancel
</button>
</ButtonsRow>
{error !== null && (
<ErrorPanel
className="mt-4"
errorMessage={
error instanceof ApiError
? error.message
: 'Something went wrong deleting segment'
}
onClose={reset}
/>
)}
</SegmentActionModal>
)
}
const FormTitle = ({ children }: { children?: ReactNode }) => (
<h1 className="text-lg font-medium text-gray-900 dark:text-gray-100 leading-7 mb-8">
{children}
</h1>
)
const ButtonsRow = ({
className,
children
}: {
className?: string
children?: ReactNode
}) => (
<div className={classNames('mt-8 flex gap-x-3 items-center', className)}>
{children}
</div>
)
const SegmentNameInput = ({
namePlaceholder,
value,
onChange
}: {
namePlaceholder: string
value: string
onChange: (value: string) => void
}) => {
return (
<>
<label
htmlFor="name"
className="block mb-1.5 text-sm font-medium dark:text-gray-100 text-gray-700 dark:text-gray-300"
>
Segment name
</label>
<input
autoComplete="off"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={namePlaceholder}
id="name"
className="block px-3.5 py-2.5 w-full text-sm dark:text-gray-300 rounded-md border border-gray-300 dark:border-gray-750 dark:bg-gray-750 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500"
/>
</>
)
}
const SegmentTypeSelector = ({
value,
onChange
}: {
value: SegmentType
onChange: (value: SegmentType) => void
}) => {
const options = [
{
type: SegmentType.personal,
name: SEGMENT_TYPE_LABELS[SegmentType.personal],
description: 'Visible only to you'
},
{
type: SegmentType.site,
name: SEGMENT_TYPE_LABELS[SegmentType.site],
description: 'Visible to others on the site'
}
]
return (
<div className="mt-6 flex flex-col gap-y-4">
{options.map(({ type, name, description }) => (
<div key={type}>
<div className="flex">
<input
checked={value === type}
id={`segment-type-${type}`}
type="radio"
value=""
onChange={() => onChange(type)}
className="mt-px size-4.5 cursor-pointer text-indigo-600 dark:bg-transparent border-gray-400 dark:border-gray-600 checked:border-indigo-600 dark:checked:border-white"
/>
<label
htmlFor={`segment-type-${type}`}
className="block ml-3 text-sm font-medium dark:text-gray-100 flex flex-col flex-inline"
>
<div>{name}</div>
<div className="text-gray-500 dark:text-gray-400 mb-2 text-sm">
{description}
</div>
</label>
</div>
</div>
))}
</div>
)
}
const useSegmentTypeDisabledState = ({
siteSegmentsAvailable,
user,
setType
}: {
siteSegmentsAvailable: boolean
user: UserContextValue
setType: (type: SegmentType) => void
}) => {
const [disabled, setDisabled] = useState<boolean>(false)
const [disabledMessage, setDisabledMessage] = useState<ReactNode | null>(null)
const userIsOwner = user.role === Role.owner
const canSelectSiteSegment = hasSiteSegmentPermission(user)
const onSegmentTypeChange = useCallback(
(type: SegmentType) => {
setType(type)
if (type === SegmentType.site && !canSelectSiteSegment) {
setDisabled(true)
setDisabledMessage(
<>
{"You don't have enough permissions to change segment to this type"}
</>
)
} else if (type === SegmentType.site && !siteSegmentsAvailable) {
setDisabled(true)
setDisabledMessage(
<>
To use this segment type,&#32;
{userIsOwner ? (
<a href="/billing/choose-plan" className="underline">
please upgrade your subscription
</a>
) : (
<>
please reach out to a team owner to upgrade their subscription.
</>
)}
</>
)
} else {
setDisabled(false)
setDisabledMessage(null)
}
},
[setType, siteSegmentsAvailable, userIsOwner, canSelectSiteSegment]
)
return {
disabled,
disabledMessage,
onSegmentTypeChange
}
}
const SaveSegmentButton = ({
disabled,
onSave
}: {
disabled: boolean
onSave: () => void
}) => {
return (
<button
className={primaryNeutralButtonClassName}
type="button"
disabled={disabled}
onClick={disabled ? () => {} : onSave}
>
Save
</button>
)
}
const SegmentTypeDisabledMessage = ({
message
}: {
message: ReactNode | null
}) => {
if (!message) return null
return (
<div className="mt-2 flex gap-x-2 text-sm">
<ExclamationTriangleIcon className="mt-1 block w-4 h-4 shrink-0" />
<div>{message}</div>
</div>
)
}
export const UpdateSegmentModal = ({
onClose,
onSave,
segment,
siteSegmentsAvailable,
user,
namePlaceholder,
status,
error,
reset
}: SegmentModalProps &
ApiRequestProps & {
onSave: (input: Pick<SavedSegment, 'id' | 'name' | 'type'>) => void
segment: SavedSegment
}) => {
const [name, setName] = useState(segment.name)
const [type, setType] = useState<SegmentType>(segment.type)
const { disabled, disabledMessage, onSegmentTypeChange } =
useSegmentTypeDisabledState({
siteSegmentsAvailable,
user,
setType
})
return (
<SegmentActionModal onClose={onClose}>
<FormTitle>Update segment</FormTitle>
<SegmentNameInput
value={name}
onChange={setName}
namePlaceholder={namePlaceholder}
/>
<SegmentTypeSelector value={type} onChange={onSegmentTypeChange} />
{disabled && <SegmentTypeDisabledMessage message={disabledMessage} />}
<ButtonsRow>
<SaveSegmentButton
disabled={status === 'pending' || disabled}
onSave={() => {
const trimmedName = name.trim()
const saveableName = trimmedName.length
? trimmedName
: namePlaceholder
onSave({ id: segment.id, name: saveableName, type })
}}
/>
<button className={secondaryButtonClassName} onClick={onClose}>
Cancel
</button>
</ButtonsRow>
{error !== null && (
<ErrorPanel
className="mt-4"
errorMessage={
error instanceof ApiError
? error.message
: 'Something went wrong updating segment'
}
onClose={reset}
/>
)}
</SegmentActionModal>
)
}
const FiltersInSegment = ({ segment_data }: { segment_data: SegmentData }) => {
return (
<>
<h2 className="font-bold dark:text-gray-100">Filters in segment</h2>
<div className="mt-2">
<FilterPillsList
className="flex-wrap"
direction="horizontal"
pills={segment_data.filters.map((filter) => ({
className: 'dark:!shadow-gray-950/60',
plainText: plainFilterText({ labels: segment_data.labels }, filter),
children: styledFilterText({ labels: segment_data.labels }, filter),
interactive: false
}))}
/>
</div>
</>
)
}
const Placeholder = ({
children,
placeholder
}: {
children: ReactNode | false
placeholder: ReactNode
}) => (
<span
className={classNames(
'rounded',
children === false &&
'bg-gray-100 dark:bg-gray-700 text-gray-100 dark:text-gray-700'
)}
>
{children === false ? placeholder : children}
</span>
)
const hasSiteSegmentPermission = (user: UserContextValue) => {
return [Role.admin, Role.owner, Role.editor, 'super_admin'].includes(
user.role
)
}
export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
const site = useSiteContext()
const user = useUserContext()
const { query } = useQueryContext()
const { segments } = useSegmentsContext()
const segment = segments
.filter((s) => isListableSegment({ segment: s, site, user }))
.find((s) => String(s.id) === String(id))
let error: ApiError | null = null
if (!segment) {
error = new ApiError(`Segment not found with with ID "${id}"`, {
error: `Segment not found with with ID "${id}"`
})
} else if (!canSeeSegmentDetails({ user })) {
error = new ApiError('Not enough permissions to see segment details', {
error: `Not enough permissions to see segment details`
})
}
const data = !error ? segment : null
return (
<ModalWithRouting maxWidth="460px">
<div className="dark:text-gray-100 mb-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-x-2">
<h1 className="text-xl font-bold break-all">
{data ? data.name : 'Segment details'}
</h1>
</div>
</div>
<div className="mt-2 text-sm/5">
<Placeholder placeholder={'Segment type'}>
{data?.segment_data ? SEGMENT_TYPE_LABELS[data.type] : false}
</Placeholder>
</div>
<div className="my-4 border-b border-gray-300 dark:border-gray-700" />
{!!data?.segment_data && (
<>
<FiltersInSegment segment_data={data.segment_data} />
<SegmentAuthorship
segment={data}
showOnlyPublicData={false}
className="mt-4 text-sm"
/>
<div className="mt-4">
<ButtonsRow>
<AppNavigationLink
className={primaryNeutralButtonClassName}
path={rootRoute.path}
search={(s) => ({
...s,
filters: data.segment_data.filters,
labels: data.segment_data.labels
})}
state={{
expandedSegment: data
}}
>
Edit segment
</AppNavigationLink>
<AppNavigationLink
className={removeFilterButtonClassname}
path={rootRoute.path}
search={(s) => {
const nonSegmentFilters = query.filters.filter(
(f) => !isSegmentFilter(f)
)
return {
...s,
filters: nonSegmentFilters,
labels: cleanLabels(
nonSegmentFilters,
query.labels,
'segment',
{}
)
}
}}
>
Remove filter
</AppNavigationLink>
</ButtonsRow>
</div>
</>
)}
{error !== null && (
<ErrorPanel
className="mt-4"
errorMessage={
error instanceof ApiError
? error.message
: 'Something went wrong loading segment'
}
onRetry={() => window.location.reload()}
/>
)}
</div>
</ModalWithRouting>
)
}