Merge branch 'dev' of https://github.com/umami-software/umami into analytics

This commit is contained in:
Francis Cao
2026-05-07 10:50:17 -07:00
45 changed files with 595 additions and 138 deletions
+1
View File
@@ -6539,6 +6539,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-compile-cache-lib@3.0.1:
+2 -1
View File
@@ -67,7 +67,8 @@ async function checkDatabaseVersion() {
async function applyMigration() {
if (!process.env.SKIP_DB_MIGRATION) {
console.log(execSync('prisma migrate deploy').toString());
const directUrl = process.env.DIRECT_DATABASE_URL || process.env.DATABASE_URL;
console.log(execSync('prisma migrate deploy', { env: { ...process.env, DATABASE_URL: directUrl } }).toString());
success('Database is up to date.');
}
+11 -2
View File
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from
import Link from '@/components/common/Link';
import { useState } from 'react';
import { DateDistance } from '@/components/common/DateDistance';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { Edit, Trash } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
@@ -21,7 +22,11 @@ export function AdminTeamsTable({
return (
<>
<DataTable data={data} {...props}>
<DataColumn id="name" label={t(labels.name)} width="1fr">
<DataColumn
id="name"
label={<SortableLabel label={t(labels.name)} sortKey="name" />}
width="1fr"
>
{(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>}
</DataColumn>
<DataColumn id="websites" label={t(labels.members)} width="140px">
@@ -41,7 +46,11 @@ export function AdminTeamsTable({
);
}}
</DataColumn>
<DataColumn id="created" label={t(labels.created)} width="160px">
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
width="160px"
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
+11 -3
View File
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, Icon, MenuItem, Modal, Row, Text } from '@umami/
import Link from '@/components/common/Link';
import { useState } from 'react';
import { DateDistance } from '@/components/common/DateDistance';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { Edit, Trash } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
@@ -22,10 +23,14 @@ export function UsersTable({
return (
<>
<DataTable data={data} {...props}>
<DataColumn id="username" label={t(labels.username)} width="2fr">
<DataColumn
id="username"
label={<SortableLabel label={t(labels.username)} sortKey="username" />}
width="2fr"
>
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
</DataColumn>
<DataColumn id="role" label={t(labels.role)}>
<DataColumn id="role" label={<SortableLabel label={t(labels.role)} sortKey="role" />}>
{(row: any) =>
t(labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown)
}
@@ -33,7 +38,10 @@ export function UsersTable({
<DataColumn id="websites" label={t(labels.websites)}>
{(row: any) => row._count.websites}
</DataColumn>
<DataColumn id="created" label={t(labels.created)}>
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
@@ -3,6 +3,7 @@ import Link from '@/components/common/Link';
import { useState } from 'react';
import { WebsiteDeleteForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
import { DateDistance } from '@/components/common/DateDistance';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { Edit, Trash, Users } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
@@ -14,14 +15,14 @@ export function AdminWebsitesTable({ data = [], ...props }: { data: any[] }) {
return (
<>
<DataTable data={data} {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{(row: any) => (
<Text truncate>
<Link href={`/admin/websites/${row.id}`}>{row.name}</Link>
</Text>
)}
</DataColumn>
<DataColumn id="domain" label={t(labels.domain)}>
<DataColumn id="domain" label={<SortableLabel label={t(labels.domain)} sortKey="domain" />}>
{(row: any) => <Text truncate>{row.domain}</Text>}
</DataColumn>
<DataColumn id="owner" label={t(labels.owner)}>
@@ -45,7 +46,11 @@ export function AdminWebsitesTable({ data = [], ...props }: { data: any[] }) {
);
}}
</DataColumn>
<DataColumn id="created" label={t(labels.created)} width="180px">
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
width="180px"
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="50px">
+12 -4
View File
@@ -1,6 +1,7 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import Link from '@/components/common/Link';
import { DateDistance } from '@/components/common/DateDistance';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages, useNavigation } from '@/components/hooks';
import { BoardDeleteButton } from './BoardDeleteButton';
import { BoardDesignButton } from './BoardDesignButton';
@@ -12,16 +13,23 @@ export function BoardsTable(props: DataTableProps) {
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/boards/${id}`)}>{name}</Link>;
}}
</DataColumn>
<DataColumn id="description" label={t(labels.description)} />
<DataColumn id="type" label={t(labels.boardType)}>
<DataColumn
id="description"
label={<SortableLabel label={t(labels.description)} sortKey="description" />}
/>
<DataColumn id="type" label={<SortableLabel label={t(labels.boardType)} sortKey="type" />}>
{({ type }: any) => type ? type.charAt(0).toUpperCase() + type.slice(1) : ''}
</DataColumn>
<DataColumn id="created" label={t(labels.created)} width="200px">
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
width="200px"
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
+9 -4
View File
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-ze
import Link from '@/components/common/Link';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { LinkDeleteButton } from './LinkDeleteButton';
import { LinkEditButton } from './LinkEditButton';
@@ -17,23 +18,27 @@ export function LinksTable({ showActions, ...props }: LinksTableProps) {
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
}}
</DataColumn>
<DataColumn id="slug" label={t(labels.link)}>
<DataColumn id="slug" label={<SortableLabel label={t(labels.link)} sortKey="slug" />}>
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
}}
</DataColumn>
<DataColumn id="url" label={t(labels.destinationUrl)}>
<DataColumn id="url" label={<SortableLabel label={t(labels.destinationUrl)} sortKey="url" />}>
{({ url }: any) => {
return <ExternalLink href={url}>{url}</ExternalLink>;
}}
</DataColumn>
<DataColumn id="created" label={t(labels.created)} width="200px">
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
width="200px"
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
+7 -3
View File
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-ze
import Link from '@/components/common/Link';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { PixelDeleteButton } from './PixelDeleteButton';
import { PixelEditButton } from './PixelEditButton';
@@ -17,12 +18,12 @@ export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
}}
</DataColumn>
<DataColumn id="url" label="URL">
<DataColumn id="url" label={<SortableLabel label="URL" sortKey="slug" />}>
{({ slug }: any) => {
const url = getSlugUrl(slug);
return (
@@ -32,7 +33,10 @@ export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
);
}}
</DataColumn>
<DataColumn id="created" label={t(labels.created)}>
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
+2 -1
View File
@@ -1,5 +1,6 @@
import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
@@ -12,7 +13,7 @@ export function TeamsTable({ renderLink, ...props }: TeamsTableProps) {
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{renderLink}
</DataColumn>
<DataColumn id="owner" label={t(labels.owner)}>
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, Row } from '@umami/react-zen';
import Link from '@/components/common/Link';
import { TeamMemberEditButton } from '@/app/(main)/teams/[teamId]/TeamMemberEditButton';
import { TeamMemberRemoveButton } from '@/app/(main)/teams/[teamId]/TeamMemberRemoveButton';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
@@ -18,10 +19,10 @@ export function TeamWebsitesTable({
return (
<DataTable data={data}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{(row: any) => <Link href={`/teams/${teamId}/websites/${row.id}`}>{row.name}</Link>}
</DataColumn>
<DataColumn id="domain" label={t(labels.domain)} />
<DataColumn id="domain" label={<SortableLabel label={t(labels.domain)} sortKey="domain" />} />
<DataColumn id="createdBy" label={t(labels.createdBy)}>
{(row: any) => row?.createUser?.username}
</DataColumn>
+3 -2
View File
@@ -1,6 +1,7 @@
import { DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { LinkButton } from '@/components/common/LinkButton';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages, useNavigation } from '@/components/hooks';
import { SquarePen } from '@/components/icons';
@@ -17,10 +18,10 @@ export function WebsitesTable({ showActions, renderLink, ...props }: WebsitesTab
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{renderLink}
</DataColumn>
<DataColumn id="domain" label={t(labels.domain)} />
<DataColumn id="domain" label={<SortableLabel label={t(labels.domain)} sortKey="domain" />} />
{showActions && (
<DataColumn id="action" label=" " align="end">
{(row: any) => {
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewAllTeams } from '@/permissions';
import { getTeams } from '@/queries/prisma/team';
@@ -9,6 +9,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewUsers } from '@/permissions';
import { getUsers } from '@/queries/prisma/user';
@@ -9,6 +9,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { ROLES } from '@/lib/constants';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewAllWebsites } from '@/permissions';
import { getWebsites } from '@/queries/prisma/website';
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -3,7 +3,7 @@ import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createBoard, getUserBoards } from '@/queries/prisma';
@@ -11,6 +11,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createLink, getUserLinks } from '@/queries/prisma';
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,12 +1,13 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { pagingParams, sortingParams } from '@/lib/schema';
import { getUserTeams } from '@/queries/prisma';
export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,12 +1,13 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { pagingParams, sortingParams } from '@/lib/schema';
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma';
export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...sortingParams,
includeTeams: z.string().optional(),
});
+2 -1
View File
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createPixel, getUserPixels } from '@/queries/prisma';
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewTeam } from '@/permissions';
import { getTeamBoards } from '@/queries/prisma';
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewTeam } from '@/permissions';
import { getTeamLinks } from '@/queries/prisma';
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewTeam } from '@/permissions';
import { getTeamPixels } from '@/queries/prisma';
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewTeam } from '@/permissions';
import { getTeamWebsites } from '@/queries/prisma';
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -5,13 +5,14 @@ import { fetchAccount } from '@/lib/load';
import redis from '@/lib/redis';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { pagingParams, sortingParams } from '@/lib/schema';
import { canCreateTeam } from '@/permissions';
import { createTeam, getUserTeams } from '@/queries/prisma';
export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,12 +1,13 @@
import { z } from 'zod';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { pagingParams, sortingParams } from '@/lib/schema';
import { getUserTeams } from '@/queries/prisma';
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({
...pagingParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,13 +1,14 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma/website';
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
includeTeams: z.string().optional(),
});
+2 -1
View File
@@ -4,7 +4,7 @@ import { uuid } from '@/lib/crypto';
import { fetchAccount } from '@/lib/load';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createShare, createWebsite, getWebsiteCount } from '@/queries/prisma';
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma/website';
@@ -15,6 +15,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
includeTeams: z.string().optional(),
});
+1 -1
View File
@@ -41,7 +41,7 @@ export function DataGrid({
const { data, error, isLoading, isFetching } = query;
const { router, updateParams, query: queryParams } = useNavigation();
const [search, setSearch] = useState(queryParams?.search || data?.search || '');
const showPager = allowPaging && data && (data.count > data.pageSize || data.isCapped);
const showPager = allowPaging && data && data.count > 0;
const { isMobile } = useMobile();
const displayMode = isMobile ? 'cards' : undefined;
+39 -26
View File
@@ -11,11 +11,18 @@ export interface PagerProps {
className?: string;
}
export function Pager({ page, pageSize, count, isCapped, onPageChange }: PagerProps) {
export function Pager({
page,
pageSize,
count,
isCapped,
onPageChange,
}: PagerProps) {
const { t, labels } = useMessages();
const maxPage = pageSize && count ? Math.ceil(+count / +pageSize) : 0;
const lastPage = page === maxPage;
const firstPage = page === 1;
const showNavigation = maxPage > 1 || isCapped;
if (count === 0 || !maxPage) {
return null;
@@ -29,34 +36,40 @@ export function Pager({ page, pageSize, count, isCapped, onPageChange }: PagerPr
}
};
if (maxPage === 1 && !isCapped) {
return null;
}
const displayCount = isCapped ? `10,000+` : (+count).toLocaleString();
return (
<Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1}>
<Text>{t(labels.numberOfRecords, { x: displayCount })}</Text>
<Row alignItems="center" justifyContent="flex-end" gap="3">
<Text>
{t(labels.pageOf, {
current: page.toLocaleString(),
total: maxPage.toLocaleString(),
})}
</Text>
<Row gap="1">
<Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}>
<Icon size="sm" rotate={180}>
<ChevronRight />
</Icon>
</Button>
<Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}>
<Icon size="sm">
<ChevronRight />
</Icon>
</Button>
</Row>
<Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1} wrap="wrap">
<Text color="muted">{t(labels.numberOfRecords, { x: displayCount })}</Text>
<Row
alignItems="center"
justifyContent="flex-end"
gap="3"
wrap="nowrap"
style={{ whiteSpace: 'nowrap' }}
>
{showNavigation && (
<>
<Text color="muted">
{t(labels.pageOf, {
current: page.toLocaleString(),
total: maxPage.toLocaleString(),
})}
</Text>
<Row gap="1">
<Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}>
<Icon size="sm" rotate={180}>
<ChevronRight />
</Icon>
</Button>
<Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}>
<Icon size="sm">
<ChevronRight />
</Icon>
</Button>
</Row>
</>
)}
</Row>
</Row>
);
+94
View File
@@ -0,0 +1,94 @@
'use client';
import type { ReactNode } from 'react';
import { Icon } from '@umami/react-zen';
import { ChevronDown, ChevronUp } from '@/components/icons';
import { useNavigation } from '@/components/hooks';
type SortDirection = 'asc' | 'desc';
export interface SortableLabelProps {
label: ReactNode;
sortKey: string;
defaultDirection?: SortDirection;
}
export function SortableLabel({
label,
sortKey,
defaultDirection = 'asc',
}: SortableLabelProps) {
const { router, query, updateParams } = useNavigation();
const isActive = query.orderBy === sortKey;
const isDescending = query.sortDescending === 'true';
const direction = isActive ? (isDescending ? 'desc' : 'asc') : undefined;
const activeColor = 'var(--text-primary)';
const getNextDirection = (): SortDirection => {
if (!isActive) {
return defaultDirection;
}
return direction === 'desc' ? 'asc' : 'desc';
};
const handleSort = () => {
const nextDirection = getNextDirection();
router.push(
updateParams({
orderBy: sortKey,
sortDescending: nextDirection === 'desc' ? 'true' : undefined,
page: 1,
}),
);
};
return (
<button
type="button"
onClick={handleSort}
className="inline-flex appearance-none items-center gap-1 border-0 bg-transparent p-0 text-inherit"
aria-pressed={isActive}
>
<span>{label}</span>
<span
aria-hidden
style={{
display: 'inline-flex',
flexDirection: 'column',
gap: 0,
lineHeight: 0,
color: 'var(--text-muted)',
opacity: 0.8,
}}
>
<span
style={{
color: direction === 'asc' ? activeColor : undefined,
opacity: direction === 'asc' ? 1 : 0.55,
transform: 'scale(0.9)',
transformOrigin: 'center',
}}
>
<Icon size="sm" color={direction === 'asc' ? undefined : 'muted'}>
<ChevronUp />
</Icon>
</span>
<span
style={{
color: direction === 'desc' ? activeColor : undefined,
opacity: direction === 'desc' ? 1 : 0.55,
marginTop: '-6px',
transform: 'scale(0.9)',
transformOrigin: 'center',
}}
>
<Icon size="sm" color={direction === 'desc' ? undefined : 'muted'}>
<ChevronDown />
</Icon>
</span>
</span>
</button>
);
}
@@ -1,14 +1,15 @@
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useUserTeamsQuery(userId: string) {
const { get, useQuery } = useApi();
const { get } = useApi();
const { modified } = useModified(`teams`);
return useQuery({
return usePagedQuery({
queryKey: ['teams', { userId, modified }],
queryFn: () => {
return get(`/users/${userId}/teams`);
queryFn: params => {
return get(`/users/${userId}/teams`, params);
},
enabled: !!userId,
});
+4 -2
View File
@@ -3,7 +3,7 @@ import { useNavigation } from './useNavigation';
export function usePageParameters() {
const {
query: { page, pageSize, search },
query: { page, pageSize, search, orderBy, sortDescending },
} = useNavigation();
return useMemo(() => {
@@ -11,6 +11,8 @@ export function usePageParameters() {
page,
pageSize,
search,
orderBy,
sortDescending,
};
}, [page, pageSize, search]);
}, [orderBy, page, pageSize, search, sortDescending]);
}
+3 -3
View File
@@ -15,13 +15,13 @@ export function usePagedQuery<TData = any, TError = Error>({
queryFn: (params?: object) => Promise<PageResult<TData>> | PageResult<TData>;
}): UseQueryResult<PageResult<TData>, TError> {
const {
query: { page, search },
query: { page, search, orderBy, sortDescending },
} = useNavigation();
const { useQuery } = useApi();
return useQuery<PageResult<TData>, TError>({
queryKey: [...queryKey, page, search] as const,
queryFn: () => queryFn({ page, search }),
queryKey: [...queryKey, page, search, orderBy, sortDescending] as const,
queryFn: () => queryFn({ page, search, orderBy, sortDescending }),
...options,
});
}
+1 -1
View File
@@ -42,7 +42,7 @@ export function DialogButton({
height,
minWidth,
minHeight,
maxHeight: 'calc(100dvh - 40px)',
maxHeight: 'min(80dvh, calc(100dvh - 40px))',
overflowY: 'auto',
padding: '32px',
};
+246
View File
@@ -0,0 +1,246 @@
import { DATA_TYPE } from '../constants';
import {
flattenJSON,
createKeyValue,
isValidDateValue,
getDataType,
getStringValue,
objectToArray,
type KeyValueData,
} from '../data';
describe('isValidDateValue', () => {
test.each([
['2024-01-15T10:30:00Z', true],
['2024-01-15T10:30:00.123Z', true],
['2024-01-15T10:30:00+02:00', true],
['not-a-date', false],
['2024/01/15', false],
['', false],
])('validates datetime strings correctly (%s → %s)', (input, expected) => {
expect(isValidDateValue(input)).toBe(expected);
});
test('returns false for non-string values', () => {
expect(isValidDateValue(123 as any)).toBe(false);
expect(isValidDateValue(null as any)).toBe(false);
expect(isValidDateValue(undefined as any)).toBe(false);
});
});
describe('getDataType', () => {
test.each([
['string', 'string'],
[123, 'number'],
[true, 'boolean'],
[null, 'object'],
[[], 'object'],
[{}, 'object'],
['2024-01-15T10:30:00Z', 'date'],
])('detects type correctly (%s → %s)', (input, expected) => {
expect(getDataType(input)).toBe(expected);
});
});
describe('createKeyValue', () => {
test('handles string values', () => {
const result = createKeyValue('name', 'test');
expect(result).toEqual({
key: 'name',
value: 'test',
dataType: DATA_TYPE.string,
});
});
test('handles number values', () => {
const result = createKeyValue('count', 42);
expect(result).toEqual({
key: 'count',
value: 42,
dataType: DATA_TYPE.number,
});
});
test('handles boolean values and converts to string', () => {
expect(createKeyValue('active', true)).toEqual({
key: 'active',
value: 'true',
dataType: DATA_TYPE.boolean,
});
expect(createKeyValue('active', false)).toEqual({
key: 'active',
value: 'false',
dataType: DATA_TYPE.boolean,
});
});
test('handles date strings', () => {
const dateStr = '2024-01-15T10:30:00Z';
const result = createKeyValue('timestamp', dateStr);
expect(result).toEqual({
key: 'timestamp',
value: dateStr,
dataType: DATA_TYPE.date,
});
});
test('handles arrays and converts to JSON string', () => {
const arr = [1, 2, 3];
const result = createKeyValue('items', arr);
expect(result).toEqual({
key: 'items',
value: '[1,2,3]',
dataType: DATA_TYPE.array,
});
});
test('handles null values', () => {
const result = createKeyValue('nullable', null);
expect(result.dataType).toBe(DATA_TYPE.array);
expect(result.value).toBe('null');
});
});
describe('getStringValue', () => {
test('formats number values with 4 decimal places', () => {
expect(getStringValue('42', DATA_TYPE.number)).toBe('42.0000');
expect(getStringValue('3.14159', DATA_TYPE.number)).toBe('3.1416');
});
test('converts date values to ISO string', () => {
const result = getStringValue('2024-01-15T10:30:00', DATA_TYPE.date);
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
test('returns string values as-is for other types', () => {
expect(getStringValue('test', DATA_TYPE.string)).toBe('test');
expect(getStringValue('true', DATA_TYPE.boolean)).toBe('true');
});
});
describe('objectToArray', () => {
test('converts object values to array', () => {
const obj = { a: 1, b: 2, c: 3 };
const result = objectToArray(obj);
expect(result).toEqual([1, 2, 3]);
});
test('returns empty array for empty object', () => {
expect(objectToArray({})).toEqual([]);
});
});
describe('flattenJSON', () => {
test('flattens simple object', () => {
const input = {
name: 'test',
count: 42,
};
const result = flattenJSON(input);
expect(result).toHaveLength(2);
expect(result).toContainEqual(expect.objectContaining({ key: 'name', value: 'test' }));
expect(result).toContainEqual(expect.objectContaining({ key: 'count', value: 42 }));
});
test('flattens nested object with dot notation', () => {
const input = {
user: {
name: 'John',
age: 30,
},
};
const result = flattenJSON(input);
expect(result).toHaveLength(2);
expect(result).toContainEqual(expect.objectContaining({ key: 'user.name', value: 'John' }));
expect(result).toContainEqual(expect.objectContaining({ key: 'user.age', value: 30 }));
});
test('flattens deeply nested object', () => {
const input = {
level1: {
level2: {
level3: {
value: 'deep',
},
},
},
};
const result = flattenJSON(input);
expect(result).toHaveLength(1);
expect(result[0].key).toBe('level1.level2.level3.value');
expect(result[0].value).toBe('deep');
});
test('treats arrays as leaf values (converts to JSON string)', () => {
const input = {
tags: ['a', 'b', 'c'],
};
const result = flattenJSON(input);
expect(result).toHaveLength(1);
expect(result[0].key).toBe('tags');
expect(result[0].value).toBe('["a","b","c"]');
expect(result[0].dataType).toBe(DATA_TYPE.array);
});
test('treats date strings as leaf values', () => {
const input = {
createdAt: '2024-01-15T10:30:00Z',
};
const result = flattenJSON(input);
expect(result).toHaveLength(1);
expect(result[0].key).toBe('createdAt');
expect(result[0].dataType).toBe(DATA_TYPE.date);
});
test('handles mixed nested and flat structure', () => {
const input = {
id: '123',
metadata: {
timestamp: '2024-01-15T10:30:00Z',
source: 'web',
details: {
referrer: 'google',
},
},
};
const result = flattenJSON(input);
const keys = result.map(r => r.key);
expect(result).toHaveLength(4);
expect(keys).toContain('id');
expect(keys).toContain('metadata.timestamp');
expect(keys).toContain('metadata.source');
expect(keys).toContain('metadata.details.referrer');
});
test('returns empty array for empty object', () => {
expect(flattenJSON({})).toEqual([]);
});
test('converts boolean values to strings', () => {
const input = {
isActive: true,
isAdmin: false,
};
const result = flattenJSON(input);
expect(result).toContainEqual(
expect.objectContaining({ key: 'isActive', value: 'true', dataType: DATA_TYPE.boolean }),
);
expect(result).toContainEqual(
expect.objectContaining({ key: 'isAdmin', value: 'false', dataType: DATA_TYPE.boolean }),
);
});
});
+23 -32
View File
@@ -1,27 +1,26 @@
import { DATA_TYPE, DATETIME_REGEX } from './constants';
import type { DynamicDataType } from './types';
export function flattenJSON(
eventData: Record<string, any>,
keyValues: { key: string; value: any; dataType: DynamicDataType }[] = [],
parentKey = '',
): { key: string; value: any; dataType: DynamicDataType }[] {
return Object.keys(eventData).reduce(
(acc, key) => {
const value = eventData[key];
const type = typeof eventData[key];
export interface KeyValueData {
key: string;
value: any;
dataType: DynamicDataType;
}
// nested object
if (value && type === 'object' && !Array.isArray(value) && !isValidDateValue(value)) {
flattenJSON(value, acc.keyValues, getKeyName(key, parentKey));
} else {
createKey(getKeyName(key, parentKey), value, acc);
export function flattenJSON(eventData: Record<string, any>): KeyValueData[] {
function flatten(obj: Record<string, any>, parentKey: string): KeyValueData[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = parentKey ? `${parentKey}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value) && !isValidDateValue(value)) {
return flatten(value, fullKey);
}
return acc;
},
{ keyValues, parentKey },
).keyValues;
return [createKeyValue(fullKey, value)];
});
}
return flatten(eventData, '');
}
export function isValidDateValue(value: string) {
@@ -50,10 +49,10 @@ export function getStringValue(value: string, dataType: number) {
return value;
}
function createKey(key: string, value: string, acc: { keyValues: any[]; parentKey: string }) {
export function createKeyValue(key: string, value: any): KeyValueData {
const type = getDataType(value);
let dataType = null;
let dataType: DynamicDataType;
let processedValue = value;
switch (type) {
case 'number':
@@ -64,29 +63,21 @@ function createKey(key: string, value: string, acc: { keyValues: any[]; parentKe
break;
case 'boolean':
dataType = DATA_TYPE.boolean;
value = value ? 'true' : 'false';
processedValue = value ? 'true' : 'false';
break;
case 'date':
dataType = DATA_TYPE.date;
break;
case 'object':
dataType = DATA_TYPE.array;
value = JSON.stringify(value);
processedValue = JSON.stringify(value);
break;
default:
dataType = DATA_TYPE.string;
break;
}
acc.keyValues.push({ key, value, dataType });
}
function getKeyName(key: string, parentKey: string) {
if (!parentKey) {
return key;
}
return `${parentKey}.${key}`;
return { key, value: processedValue, dataType };
}
export function objectToArray(obj: object) {
+10
View File
@@ -82,6 +82,16 @@ export const pagingParams = {
export const sortingParams = {
orderBy: z.string().optional(),
sortDescending: z
.enum(['true', 'false'])
.optional()
.transform(value => {
if (value === undefined) {
return undefined;
}
return value === 'true';
}),
};
export const userRoleParam = z.enum(['admin', 'user', 'view-only']);
+27
View File
@@ -0,0 +1,27 @@
import type { QueryFilters } from './types';
export function sanitizeSortFilters<const T extends readonly string[]>(
filters: QueryFilters = {},
allowedFields: T,
defaults: Partial<Pick<QueryFilters, 'orderBy' | 'sortDescending'>> = {},
): QueryFilters {
const { orderBy, sortDescending, ...rest } = filters;
const fallbackOrderBy = defaults.orderBy;
const fallbackSortDescending = defaults.sortDescending;
const isAllowed = orderBy ? allowedFields.includes(orderBy as T[number]) : false;
return {
...rest,
...(isAllowed
? {
orderBy,
sortDescending,
}
: {
...(fallbackOrderBy && { orderBy: fallbackOrderBy }),
...(fallbackSortDescending !== undefined && {
sortDescending: fallbackSortDescending,
}),
}),
};
}
+6 -2
View File
@@ -1,8 +1,11 @@
import type { Prisma } from '@/generated/prisma/client';
import { BOARD_TYPES } from '@/lib/boards';
import prisma from '@/lib/prisma';
import { sanitizeSortFilters } from '@/lib/sort';
import type { QueryFilters } from '@/lib/types';
const BOARD_SORT_FIELDS = ['name', 'description', 'type', 'createdAt'] as const;
export async function findBoard(criteria: Prisma.BoardFindUniqueArgs) {
return prisma.client.board.findUnique(criteria);
}
@@ -16,7 +19,8 @@ export async function getBoard(boardId: string) {
}
export async function getBoards(criteria: Prisma.BoardFindManyArgs, filters: QueryFilters = {}) {
const { search } = filters;
const sortFilters = sanitizeSortFilters(filters, BOARD_SORT_FIELDS);
const { search } = sortFilters;
const { getSearchParameters, pagedQuery } = prisma;
const where: Prisma.BoardWhereInput = {
@@ -24,7 +28,7 @@ export async function getBoards(criteria: Prisma.BoardFindManyArgs, filters: Que
...getSearchParameters(search, [{ name: 'contains' }, { description: 'contains' }]),
};
return pagedQuery('board', { ...criteria, where }, filters);
return pagedQuery('board', { ...criteria, where }, sortFilters);
}
export async function getUserBoards(userId: string, filters?: QueryFilters) {
+6 -2
View File
@@ -1,7 +1,10 @@
import type { Prisma } from '@/generated/prisma/client';
import prisma from '@/lib/prisma';
import { sanitizeSortFilters } from '@/lib/sort';
import type { QueryFilters } from '@/lib/types';
const LINK_SORT_FIELDS = ['name', 'slug', 'url', 'createdAt'] as const;
export async function findLink(criteria: Prisma.LinkFindUniqueArgs) {
return prisma.client.link.findUnique(criteria);
}
@@ -15,7 +18,8 @@ export async function getLink(linkId: string) {
}
export async function getLinks(criteria: Prisma.LinkFindManyArgs, filters: QueryFilters = {}) {
const { search } = filters;
const sortFilters = sanitizeSortFilters(filters, LINK_SORT_FIELDS);
const { search } = sortFilters;
const { getSearchParameters, pagedQuery } = prisma;
const where: Prisma.LinkWhereInput = {
@@ -27,7 +31,7 @@ export async function getLinks(criteria: Prisma.LinkFindManyArgs, filters: Query
]),
};
return pagedQuery('link', { ...criteria, where }, filters);
return pagedQuery('link', { ...criteria, where }, sortFilters);
}
export async function getUserLinks(userId: string, filters?: QueryFilters) {
+6 -2
View File
@@ -1,7 +1,10 @@
import type { Prisma } from '@/generated/prisma/client';
import prisma from '@/lib/prisma';
import { sanitizeSortFilters } from '@/lib/sort';
import type { QueryFilters } from '@/lib/types';
const PIXEL_SORT_FIELDS = ['name', 'slug', 'createdAt'] as const;
export async function findPixel(criteria: Prisma.PixelFindUniqueArgs) {
return prisma.client.pixel.findUnique(criteria);
}
@@ -15,14 +18,15 @@ export async function getPixel(pixelId: string) {
}
export async function getPixels(criteria: Prisma.PixelFindManyArgs, filters: QueryFilters = {}) {
const { search } = filters;
const sortFilters = sanitizeSortFilters(filters, PIXEL_SORT_FIELDS);
const { search } = sortFilters;
const where: Prisma.PixelWhereInput = {
...criteria.where,
...prisma.getSearchParameters(search, [{ name: 'contains' }, { slug: 'contains' }]),
};
return prisma.pagedQuery('pixel', { ...criteria, where }, filters);
return prisma.pagedQuery('pixel', { ...criteria, where }, sortFilters);
}
export async function getUserPixels(userId: string, filters?: QueryFilters) {
+6 -2
View File
@@ -2,10 +2,13 @@ import { Prisma, type Team } from '@/generated/prisma/client';
import { ROLES } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
import prisma from '@/lib/prisma';
import { sanitizeSortFilters } from '@/lib/sort';
import type { PageResult, QueryFilters } from '@/lib/types';
import TeamFindManyArgs = Prisma.TeamFindManyArgs;
const TEAM_SORT_FIELDS = ['name', 'createdAt'] as const;
export async function findTeam(criteria: Prisma.TeamFindUniqueArgs): Promise<Team> {
return prisma.client.team.findUnique(criteria);
}
@@ -29,7 +32,8 @@ export async function getTeams(
filters: QueryFilters,
): Promise<PageResult<Team[]>> {
const { getSearchParameters } = prisma;
const { search } = filters;
const sortFilters = sanitizeSortFilters(filters, TEAM_SORT_FIELDS);
const { search } = sortFilters;
const where: Prisma.TeamWhereInput = {
...criteria.where,
@@ -42,7 +46,7 @@ export async function getTeams(
...criteria,
where,
},
filters,
sortFilters,
);
}
+9 -6
View File
@@ -2,10 +2,13 @@ import { Prisma } from '@/generated/prisma/client';
import { ROLES } from '@/lib/constants';
import { getRandomChars } from '@/lib/generate';
import prisma from '@/lib/prisma';
import { sanitizeSortFilters } from '@/lib/sort';
import type { QueryFilters, Role } from '@/lib/types';
import UserFindManyArgs = Prisma.UserFindManyArgs;
const USER_SORT_FIELDS = ['username', 'role', 'createdAt'] as const;
export interface GetUserOptions {
includePassword?: boolean;
showDeleted?: boolean;
@@ -46,7 +49,11 @@ export async function getUserByUsername(username: string, options: GetUserOption
}
export async function getUsers(criteria: UserFindManyArgs, filters: QueryFilters = {}) {
const { search } = filters;
const sortFilters = sanitizeSortFilters(filters, USER_SORT_FIELDS, {
orderBy: 'createdAt',
sortDescending: true,
});
const { search } = sortFilters;
const where: Prisma.UserWhereInput = {
...criteria.where,
@@ -60,11 +67,7 @@ export async function getUsers(criteria: UserFindManyArgs, filters: QueryFilters
...criteria,
where,
},
{
orderBy: 'createdAt',
sortDescending: true,
...filters,
},
sortFilters,
);
}
+8 -10
View File
@@ -2,8 +2,11 @@ import type { Prisma, Website } from '@/generated/prisma/client';
import { ROLES } from '@/lib/constants';
import prisma from '@/lib/prisma';
import redis from '@/lib/redis';
import { sanitizeSortFilters } from '@/lib/sort';
import type { QueryFilters } from '@/lib/types';
const WEBSITE_SORT_FIELDS = ['name', 'domain', 'createdAt'] as const;
export async function findWebsite(criteria: Prisma.WebsiteFindUniqueArgs) {
return prisma.client.website.findUnique(criteria);
}
@@ -23,7 +26,8 @@ export async function getWebsite(websiteId: string) {
}
export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) {
const { search } = filters;
const sortFilters = sanitizeSortFilters(filters, WEBSITE_SORT_FIELDS);
const { search } = sortFilters;
const { getSearchParameters, pagedQuery } = prisma;
const where: Prisma.WebsiteWhereInput = {
@@ -37,7 +41,7 @@ export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters:
deletedAt: null,
};
const websites = await pagedQuery('website', { ...criteria, where }, filters);
const websites = await pagedQuery('website', { ...criteria, where }, sortFilters);
return attachShareIdToWebsites(websites);
}
@@ -62,10 +66,7 @@ export async function getAllUserWebsitesIncludingTeamAccess(userId: string, filt
],
},
},
{
orderBy: 'name',
...filters,
},
sanitizeSortFilters(filters, WEBSITE_SORT_FIELDS, { orderBy: 'name' }),
);
}
@@ -84,10 +85,7 @@ export async function getUserWebsites(userId: string, filters?: QueryFilters) {
},
},
},
{
orderBy: 'name',
...filters,
},
sanitizeSortFilters(filters, WEBSITE_SORT_FIELDS, { orderBy: 'name' }),
);
}
-4
View File
@@ -96,10 +96,6 @@ async function clickhouseQuery(websiteId: string, column: string, filters: Query
excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`;
}
if (search) {
searchQuery = `and positionCaseInsensitive(${column}, {search:String}) > 0`;
}
if (search) {
if (decodeURIComponent(search).includes(',')) {
searchQuery = `AND (${decodeURIComponent(search)