Cohorts editing.

This commit is contained in:
Mike Cao 2025-08-26 23:55:57 -07:00
parent 07665f4824
commit 8c8e36c63b
26 changed files with 1066 additions and 985 deletions

View file

@ -70,7 +70,11 @@ export function SideNav(props: SidebarProps) {
<Row height="100%" backgroundColor border="right">
<Sidebar {...props} isCollapsed={isCollapsed || hasNav} muteItems={false} showBorder={false}>
<SidebarSection onClick={() => setIsCollapsed(false)}>
<SidebarHeader label="umami" icon={isCollapsed && !hasNav ? <PanelLeft /> : <Logo />}>
<SidebarHeader
label="umami"
icon={isCollapsed && !hasNav ? <PanelLeft /> : <Logo />}
style={{ maxHeight: 40 }}
>
{!isCollapsed && !hasNav && <PanelButton />}
</SidebarHeader>
</SidebarSection>
@ -81,7 +85,12 @@ export function SideNav(props: SidebarProps) {
{links.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={renderUrl(path, false)} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.endsWith(path)} />
<SidebarItem
label={label}
icon={icon}
isSelected={pathname.endsWith(path)}
role="button"
/>
</Link>
);
})}
@ -90,7 +99,12 @@ export function SideNav(props: SidebarProps) {
{bottomLinks.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={path} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.includes(path)} />
<SidebarItem
label={label}
icon={icon}
isSelected={pathname.includes(path)}
role="button"
/>
</Link>
);
})}

View file

@ -8,17 +8,14 @@ export function DateRangeSetting() {
const { dateRange, saveDateRange } = useDateRange();
const { value } = dateRange;
const handleChange = (value: string) => saveDateRange(value);
const handleChange = (value: string) => {
saveDateRange(value);
};
const handleReset = () => saveDateRange(DEFAULT_DATE_RANGE_VALUE);
return (
<Row gap="3">
<DateFilter
value={value}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={handleChange}
/>
<DateFilter value={value} onChange={handleChange} />
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
</Row>
);

View file

@ -15,7 +15,7 @@ export function CohortAddButton({ websiteId }: { websiteId: string }) {
<Text>{formatMessage(labels.cohort)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.cohort)} style={{ width: 800, maxHeight: '90vh' }}>
<Dialog title={formatMessage(labels.cohort)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => {
return <CohortEditForm websiteId={websiteId} onClose={close} />;
}}

View file

@ -17,7 +17,7 @@ export function CohortEditButton({
return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog title={formatMessage(labels.cohort)} style={{ width: 800, maxHeight: '90vh' }}>
<Dialog title={formatMessage(labels.cohort)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => {
return (
<CohortEditForm

View file

@ -7,18 +7,29 @@ import {
TextField,
Label,
Loading,
Column,
ComboBox,
Select,
ListItem,
Grid,
useDebounce,
} from '@umami/react-zen';
import { subMonths, endOfDay } from 'date-fns';
import {
useMessages,
useUpdateQuery,
useWebsiteCohortQuery,
useWebsiteValuesQuery,
} from '@/components/hooks';
import { DateFilter } from '@/components/input/DateFilter';
import { FieldFilters } from '@/components/input/FieldFilters';
import { useState } from 'react';
import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks';
import { filtersArrayToObject } from '@/lib/params';
import { SetStateAction, useMemo, useState } from 'react';
import { endOfDay, subMonths } from 'date-fns';
import { Empty } from '@/components/common/Empty';
export function CohortEditForm({
cohortId,
websiteId,
filters = [],
showFilters = true,
onSave,
onClose,
}: {
@ -29,32 +40,46 @@ export function CohortEditForm({
onSave?: () => void;
onClose?: () => void;
}) {
const [action, setAction] = useState('path');
const [search, setSearch] = useState('');
const searchValue = useDebounce(search, 300);
const { data } = useWebsiteCohortQuery(websiteId, cohortId);
const { formatMessage, labels, messages } = useMessages();
const [currentFilters, setCurrentFilters] = useState(filters);
const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date());
const { data: searchResults, isLoading } = useWebsiteValuesQuery({
websiteId,
type: action,
search: searchValue,
startDate,
endDate,
});
const { mutate, error, isPending, touch, toast } = useUpdateQuery(
`/websites/${websiteId}/cohorts${cohortId ? `/${cohortId}` : ''}`,
`/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`,
{
...data,
type: 'cohort',
},
);
const handleSubmit = async (data: any) => {
mutate(
{ ...data, parameters: filtersArrayToObject(currentFilters) },
{
onSuccess: async () => {
toast(formatMessage(messages.save));
touch('cohorts');
onSave?.();
onClose?.();
},
const items: string[] = useMemo(() => {
return searchResults?.map(({ value }) => value) || [];
}, [searchResults]);
const handleSubmit = async (formData: any) => {
mutate(formData, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('cohorts');
onSave?.();
onClose?.();
},
);
});
};
const handleSearch = (value: SetStateAction<string>) => {
setSearch(value);
};
if (cohortId && !data) {
@ -62,7 +87,11 @@ export function CohortEditForm({
}
return (
<Form error={error} onSubmit={handleSubmit} defaultValues={data}>
<Form
error={error}
onSubmit={handleSubmit}
defaultValues={data || { parameters: { filters, dateRange: '30day' } }}
>
<FormField
name="name"
label={formatMessage(labels.name)}
@ -70,27 +99,74 @@ export function CohortEditForm({
>
<TextField autoFocus />
</FormField>
{showFilters && (
<>
<Label>{formatMessage(labels.filters)}</Label>
<FieldFilters
websiteId={websiteId}
filters={currentFilters}
startDate={startDate}
endDate={endDate}
onSave={setCurrentFilters}
/>
</>
)}
<Column>
<Label>{formatMessage(labels.action)}</Label>
<Grid columns="260px 1fr" gap>
<Column>
<Select value={action} onChange={setAction}>
<ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
<ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
</Select>
</Column>
<Column>
<FormField
name="parameters.action"
rules={{ required: formatMessage(labels.required) }}
>
{({ field }) => {
return (
<ComboBox
aria-label="action"
items={items}
inputValue={field?.value}
onInputChange={value => {
handleSearch(value);
field?.onChange?.(value);
}}
formValue="text"
allowsEmptyCollection
allowsCustomValue
renderEmptyState={() =>
isLoading ? (
<Loading position="center" icon="dots" />
) : (
<Empty message={formatMessage(messages.noResultsFound)} />
)
}
>
{items.map(item => (
<ListItem key={item} id={item}>
{item}
</ListItem>
))}
</ComboBox>
);
}}
</FormField>
</Column>
</Grid>
</Column>
<Column width="260px">
<Label>{formatMessage(labels.dateRange)}</Label>
<FormField name="parameters.dateRange" rules={{ required: formatMessage(labels.required) }}>
<DateFilter placement="bottom start" />
</FormField>
</Column>
<Column>
<Label>{formatMessage(labels.filters)}</Label>
<FormField name="parameters.filters" rules={{ required: formatMessage(labels.required) }}>
<FieldFilters websiteId={websiteId} exclude={['path', 'event']} />
</FormField>
</Column>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton
variant="primary"
data-test="button-submit"
isDisabled={isPending || currentFilters.length === 0}
>
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>

View file

@ -15,7 +15,7 @@ export function SegmentAddButton({ websiteId }: { websiteId: string }) {
<Text>{formatMessage(labels.segment)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.segment)} style={{ width: 800, maxHeight: '90vh' }}>
<Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => {
return <SegmentEditForm websiteId={websiteId} onClose={close} />;
}}

View file

@ -11,13 +11,13 @@ export function SegmentEditButton({
}: {
segmentId: string;
websiteId: string;
filters: any[];
filters?: any[];
}) {
const { formatMessage, labels } = useMessages();
return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog title={formatMessage(labels.segment)} style={{ width: 800, maxHeight: '90vh' }}>
<Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => {
return (
<SegmentEditForm

View file

@ -5,14 +5,11 @@ import {
FormField,
FormSubmitButton,
TextField,
Label,
Loading,
Label,
} from '@umami/react-zen';
import { subMonths, endOfDay } from 'date-fns';
import { FieldFilters } from '@/components/input/FieldFilters';
import { useState } from 'react';
import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks';
import { filtersArrayToObject } from '@/lib/params';
import { messages } from '@/components/messages';
export function SegmentEditForm({
@ -32,30 +29,23 @@ export function SegmentEditForm({
}) {
const { data } = useWebsiteSegmentQuery(websiteId, segmentId);
const { formatMessage, labels } = useMessages();
const [currentFilters, setCurrentFilters] = useState(filters);
const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date());
const { mutate, error, isPending, touch, toast } = useUpdateQuery(
`/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`,
{
...data,
type: 'segment',
},
);
const handleSubmit = async (data: any) => {
mutate(
{ ...data, parameters: filtersArrayToObject(currentFilters) },
{
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('segments');
onSave?.();
onClose?.();
},
const handleSubmit = async (formData: any) => {
mutate(formData, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('segments');
onSave?.();
onClose?.();
},
);
});
};
if (segmentId && !data) {
@ -63,35 +53,27 @@ export function SegmentEditForm({
}
return (
<Form error={error} onSubmit={handleSubmit} defaultValues={data}>
<Form error={error} onSubmit={handleSubmit} defaultValues={data || { parameters: { filters } }}>
<FormField
name="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoFocus />
<TextField autoFocus={!segmentId} />
</FormField>
{showFilters && (
<>
<Label>{formatMessage(labels.filters)}</Label>
<FieldFilters
websiteId={websiteId}
filters={currentFilters}
startDate={startDate}
endDate={endDate}
onSave={setCurrentFilters}
/>
<FormField name="parameters.filters" rules={{ required: formatMessage(labels.required) }}>
<FieldFilters websiteId={websiteId} />
</FormField>
</>
)}
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton
variant="primary"
data-test="button-submit"
isDisabled={isPending || currentFilters.length === 0}
>
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>

View file

@ -2,7 +2,6 @@ import { DataTable, DataColumn, Row } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks';
import { Empty } from '@/components/common/Empty';
import { DateDistance } from '@/components/common/DateDistance';
import { filtersObjectToArray } from '@/lib/params';
import { SegmentEditButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditButton';
import { SegmentDeleteButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton';
import Link from 'next/link';
@ -27,15 +26,11 @@ export function SegmentsTable({ data = [] }) {
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{(row: any) => {
const { id, name, parameters } = row;
const { id, name } = row;
return (
<Row>
<SegmentEditButton
segmentId={id}
websiteId={websiteId}
filters={filtersObjectToArray(parameters)}
/>
<SegmentEditButton segmentId={id} websiteId={websiteId} />
<SegmentDeleteButton segmentId={id} websiteId={websiteId} name={name} />
</Row>
);

View file

@ -4,14 +4,14 @@ import { getQueryFilters, parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response';
import { getWebsiteSegments, getValues } from '@/queries';
import { z } from 'zod';
import { dateRangeParams, searchParams } from '@/lib/schema';
import { dateRangeParams, fieldsParam, searchParams } from '@/lib/schema';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
type: z.string(),
type: fieldsParam,
...dateRangeParams,
...searchParams,
});
@ -31,7 +31,7 @@ export async function GET(
const { type } = query;
if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !FILTER_GROUPS[type]) {
return badRequest('Invalid type.');
return badRequest();
}
let values;