diff --git a/src/app/(main)/admin/teams/AdminTeamsPage.tsx b/src/app/(main)/admin/teams/AdminTeamsPage.tsx index 41e6f4af..7905f7f6 100644 --- a/src/app/(main)/admin/teams/AdminTeamsPage.tsx +++ b/src/app/(main)/admin/teams/AdminTeamsPage.tsx @@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; import { useMessages } from '@/components/hooks'; +import { TeamsAddButton } from '../../teams/TeamsAddButton'; import { AdminTeamsDataTable } from './AdminTeamsDataTable'; export function AdminTeamsPage() { const { formatMessage, labels } = useMessages(); + const handleSave = () => {}; + return ( - + + + diff --git a/src/app/(main)/teams/TeamAddForm.tsx b/src/app/(main)/teams/TeamAddForm.tsx index c95259f4..3b827776 100644 --- a/src/app/(main)/teams/TeamAddForm.tsx +++ b/src/app/(main)/teams/TeamAddForm.tsx @@ -7,8 +7,17 @@ import { TextField, } from '@umami/react-zen'; import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { UserSelect } from '@/components/input/UserSelect'; -export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { +export function TeamAddForm({ + onSave, + onClose, + isAdmin, +}: { + onSave: () => void; + onClose: () => void; + isAdmin: boolean; +}) { const { formatMessage, labels, getErrorMessage } = useMessages(); const { mutateAsync, error, isPending } = useUpdateQuery('/teams'); @@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: + {isAdmin && ( + + + + )} + + {formatMessage(labels.save)} + + + + ); +} diff --git a/src/app/(main)/teams/TeamsAddButton.tsx b/src/app/(main)/teams/TeamsAddButton.tsx index 578a273a..13873088 100644 --- a/src/app/(main)/teams/TeamsAddButton.tsx +++ b/src/app/(main)/teams/TeamsAddButton.tsx @@ -4,7 +4,13 @@ import { Plus } from '@/components/icons'; import { messages } from '@/components/messages'; import { TeamAddForm } from './TeamAddForm'; -export function TeamsAddButton({ onSave }: { onSave?: () => void }) { +export function TeamsAddButton({ + onSave, + isAdmin = false, +}: { + onSave?: () => void; + isAdmin?: boolean; +}) { const { formatMessage, labels } = useMessages(); const { toast } = useToast(); const { touch } = useModified(); @@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) { - {({ close }) => } + {({ close }) => } diff --git a/src/app/(main)/teams/TeamsMemberAddButton.tsx b/src/app/(main)/teams/TeamsMemberAddButton.tsx new file mode 100644 index 00000000..f1bbf258 --- /dev/null +++ b/src/app/(main)/teams/TeamsMemberAddButton.tsx @@ -0,0 +1,40 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { messages } from '@/components/messages'; +import { TeamMemberAddForm } from './TeamMemberAddForm'; + +export function TeamsMemberAddButton({ + teamId, + onSave, +}: { + teamId: string; + onSave?: () => void; + isAdmin?: boolean; +}) { + const { formatMessage, labels } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = async () => { + toast(formatMessage(messages.saved)); + touch('teams:members'); + onSave?.(); + }; + + return ( + + + + + {({ close }) => } + + + + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamSettings.tsx b/src/app/(main)/teams/[teamId]/TeamSettings.tsx index 3ddbe000..4bbb8905 100644 --- a/src/app/(main)/teams/[teamId]/TeamSettings.tsx +++ b/src/app/(main)/teams/[teamId]/TeamSettings.tsx @@ -1,10 +1,12 @@ -import { Column } from '@umami/react-zen'; +import { Column, Heading, Row } from '@umami/react-zen'; import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; -import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks'; +import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks'; import { Users } from '@/components/icons'; +import { labels } from '@/components/messages'; import { ROLES } from '@/lib/constants'; +import { TeamsMemberAddButton } from '../TeamsMemberAddButton'; import { TeamEditForm } from './TeamEditForm'; import { TeamManage } from './TeamManage'; import { TeamMembersDataTable } from './TeamMembersDataTable'; @@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) { const team: any = useTeam(); const { user } = useLoginQuery(); const { pathname } = useNavigation(); + const { formatMessage } = useMessages(); const isAdmin = pathname.includes('/admin'); @@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) { + + {formatMessage(labels.members)} + {isAdmin && } + {isTeamOwner && ( diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx index c2dd8349..f1a8976f 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx @@ -1,10 +1,10 @@ 'use client'; import { Column, Grid, ListItem, Row, SearchField, Select } from '@umami/react-zen'; -import { FilterButtons } from 'dist'; import { useState } from 'react'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { Panel } from '@/components/common/Panel'; import { useDateRange, useMessages } from '@/components/hooks'; +import { FilterButtons } from '@/components/input/FilterButtons'; import { Journey } from './Journey'; const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7]; diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx index 29c3954f..4bac4ff6 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx @@ -169,6 +169,12 @@ export function WebsiteExpandedMenu({ path: updateParams({ view: 'hostname' }), icon: , }, + { + id: 'distinctId', + label: formatMessage(labels.distinctId), + path: updateParams({ view: 'distinctId' }), + icon: , + }, { id: 'tag', label: formatMessage(labels.tag), diff --git a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx index 13c05160..4daf17fc 100644 --- a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx +++ b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx @@ -93,6 +93,11 @@ export function CompareTables({ websiteId }: { websiteId: string }) { label: formatMessage(labels.hostname), path: renderPath('hostname'), }, + { + id: 'distinctId', + label: formatMessage(labels.distinctId), + path: renderPath('distinctId'), + }, { id: 'tag', label: formatMessage(labels.tags), diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 53ef5923..c571f405 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -28,6 +28,7 @@ export async function GET(request: Request) { export async function POST(request: Request) { const schema = z.object({ name: z.string().max(50), + ownerId: z.uuid().optional(), }); const { auth, body, error } = await parseRequest(request, schema); @@ -40,7 +41,7 @@ export async function POST(request: Request) { return unauthorized(); } - const { name } = body; + const { name, ownerId } = body; const team = await createTeam( { @@ -48,7 +49,7 @@ export async function POST(request: Request) { name, accessCode: `team_${getRandomChars(16)}`, }, - auth.user.id, + ownerId ?? auth.user.id, ); return json(team); diff --git a/src/components/hooks/useFields.ts b/src/components/hooks/useFields.ts index 22a1dcf3..039b7157 100644 --- a/src/components/hooks/useFields.ts +++ b/src/components/hooks/useFields.ts @@ -15,6 +15,7 @@ export function useFields() { { name: 'region', type: 'string', label: formatMessage(labels.region) }, { name: 'city', type: 'string', label: formatMessage(labels.city) }, { name: 'hostname', type: 'string', label: formatMessage(labels.hostname) }, + { name: 'distinctId', type: 'string', label: formatMessage(labels.distinctId) }, { name: 'tag', type: 'string', label: formatMessage(labels.tag) }, { name: 'event', type: 'string', label: formatMessage(labels.event) }, ]; diff --git a/src/components/hooks/useFilterParameters.ts b/src/components/hooks/useFilterParameters.ts index 54032120..c141a3be 100644 --- a/src/components/hooks/useFilterParameters.ts +++ b/src/components/hooks/useFilterParameters.ts @@ -18,6 +18,7 @@ export function useFilterParameters() { event, tag, hostname, + distinctId, page, pageSize, search, @@ -42,6 +43,7 @@ export function useFilterParameters() { event, tag, hostname, + distinctId, search, segment, cohort, @@ -61,6 +63,7 @@ export function useFilterParameters() { event, tag, hostname, + distinctId, page, pageSize, search, diff --git a/src/components/input/FilterEditForm.tsx b/src/components/input/FilterEditForm.tsx index 44f43844..9221e3a2 100644 --- a/src/components/input/FilterEditForm.tsx +++ b/src/components/input/FilterEditForm.tsx @@ -61,7 +61,9 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP websiteId={websiteId} value={currentFilters} onChange={setCurrentFilters} - exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []} + exclude={ + excludeFilters ? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event'] : [] + } /> diff --git a/src/components/input/UserSelect.tsx b/src/components/input/UserSelect.tsx new file mode 100644 index 00000000..ccb3d432 --- /dev/null +++ b/src/components/input/UserSelect.tsx @@ -0,0 +1,71 @@ +import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { useMessages, useTeamMembersQuery, useUsersQuery } from '@/components/hooks'; + +export function UserSelect({ + teamId, + onChange, + ...props +}: { + teamId?: string; +} & SelectProps) { + const { formatMessage, messages } = useMessages(); + const { data: users, isLoading: usersLoading } = useUsersQuery(); + const { data: teamMembers, isLoading: teamMembersLoading } = useTeamMembersQuery(teamId); + const [username, setUsername] = useState(); + const [search, setSearch] = useState(''); + + const listItems = useMemo(() => { + if (!users) { + return []; + } + if (!teamId || !teamMembers) { + return users.data; + } + const teamMemberIds = teamMembers.data.map(({ userId }) => userId); + return users.data.filter(({ id }) => !teamMemberIds.includes(id)); + }, [users, teamMembers, teamId]); + + const handleSearch = (value: string) => { + setSearch(value); + }; + + const handleOpenChange = () => { + setSearch(''); + }; + + const handleChange = (id: string) => { + setUsername(listItems.find(item => item.id === id)?.username); + onChange(id); + }; + + const renderValue = () => { + return ( + + {username} + + ); + }; + + return ( + + ); +} diff --git a/src/index.ts b/src/index.ts index 907c5623..df164b9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,11 +19,13 @@ export * from '@/app/(main)/teams/TeamAddForm'; export * from '@/app/(main)/teams/TeamJoinForm'; export * from '@/app/(main)/teams/TeamLeaveButton'; export * from '@/app/(main)/teams/TeamLeaveForm'; +export * from '@/app/(main)/teams/TeamMemberAddForm'; export * from '@/app/(main)/teams/TeamProvider'; export * from '@/app/(main)/teams/TeamsAddButton'; export * from '@/app/(main)/teams/TeamsDataTable'; export * from '@/app/(main)/teams/TeamsHeader'; export * from '@/app/(main)/teams/TeamsJoinButton'; +export * from '@/app/(main)/teams/TeamsMemberAddButton'; export * from '@/app/(main)/teams/TeamsTable'; export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData'; export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm'; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index bfc80a13..3da177c0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -55,6 +55,7 @@ export const SESSION_COLUMNS = [ 'country', 'city', 'region', + 'distinctId', ]; export const SEGMENT_TYPES = { @@ -69,6 +70,7 @@ export const FILTER_COLUMNS = { referrer: 'referrer_domain', domain: 'referrer_domain', hostname: 'hostname', + distinctId: 'distinct_id', title: 'page_title', query: 'url_query', os: 'os', diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 247a89ae..a3c56a0f 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -36,6 +36,7 @@ export const filterParams = { city: z.string().optional(), tag: z.string().optional(), hostname: z.string().optional(), + distinctId: z.string().optional(), language: z.string().optional(), event: z.string().optional(), segment: z.uuid().optional(), @@ -89,6 +90,7 @@ export const fieldsParam = z.enum([ 'city', 'tag', 'hostname', + 'distinctId', 'language', 'event', ]); diff --git a/src/queries/sql/events/getEventExpandedMetrics.ts b/src/queries/sql/events/getEventExpandedMetrics.ts index dec444e8..86bda850 100644 --- a/src/queries/sql/events/getEventExpandedMetrics.ts +++ b/src/queries/sql/events/getEventExpandedMetrics.ts @@ -72,6 +72,7 @@ async function relationalQuery( ${filterQuery} group by name, website_event.session_id, website_event.visit_id ) as t + where name != '' group by name order by visitors desc, visits desc limit ${limit} diff --git a/src/queries/sql/reports/getBreakdown.ts b/src/queries/sql/reports/getBreakdown.ts index 51773d86..c84db769 100644 --- a/src/queries/sql/reports/getBreakdown.ts +++ b/src/queries/sql/reports/getBreakdown.ts @@ -131,5 +131,5 @@ function parseFields(fields: string[]) { } function parseFieldsByName(fields: string[]) { - return `${fields.map(name => name).join(',')}`; + return `${fields.map(name => `"${name}"`).join(',')}`; } diff --git a/src/queries/sql/sessions/getSessionExpandedMetrics.ts b/src/queries/sql/sessions/getSessionExpandedMetrics.ts index c8d20d84..6b85cd45 100644 --- a/src/queries/sql/sessions/getSessionExpandedMetrics.ts +++ b/src/queries/sql/sessions/getSessionExpandedMetrics.ts @@ -82,6 +82,7 @@ async function relationalQuery( group by name, website_event.session_id, website_event.visit_id ${includeCountry ? ', country' : ''} ) as t + where name != '' group by name ${includeCountry ? ', country' : ''} order by visitors desc, visits desc