605 lines
16 KiB
TypeScript
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, 
|
|
{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>
|
|
)
|
|
}
|