implement datatable sorting on non-analytics tables. (website/board/link/pixel/team/admin)

This commit is contained in:
Francis Cao
2026-05-07 10:46:03 -07:00
parent b5c4dbfa56
commit 5ccfb6d0a5
39 changed files with 322 additions and 100 deletions
+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,
});
}
+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' }),
);
}