mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
Boards components.
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
This commit is contained in:
parent
7edddf15a7
commit
a39ebffd8b
20 changed files with 450 additions and 33 deletions
|
|
@ -16,7 +16,7 @@ export function BoardAddButton() {
|
|||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button data-test="button-website-add" variant="primary">
|
||||
<Button data-test="button-board-add" variant="primary">
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Button, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen';
|
||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
import { DOMAIN_REGEX } from '@/lib/constants';
|
||||
|
||||
export function BoardAddForm({
|
||||
teamId,
|
||||
|
|
@ -11,39 +10,38 @@ export function BoardAddForm({
|
|||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { mutateAsync, error, isPending } = useUpdateQuery('/websites', { teamId });
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { mutateAsync, error, isPending } = useUpdateQuery('/boards', { teamId });
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await mutateAsync(data, {
|
||||
onSuccess: async () => {
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
await mutateAsync(
|
||||
{ type: 'board', ...data },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={error?.message}>
|
||||
<FormField
|
||||
label={formatMessage(labels.name)}
|
||||
data-test="input-name"
|
||||
name="name"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={formatMessage(labels.domain)}
|
||||
data-test="input-domain"
|
||||
name="domain"
|
||||
label={formatMessage(labels.description)}
|
||||
name="description"
|
||||
rules={{
|
||||
required: formatMessage(labels.required),
|
||||
pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
|
||||
}}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
<TextField asTextArea autoComplete="off" />
|
||||
</FormField>
|
||||
<Row justifyContent="flex-end" paddingTop="3" gap="3">
|
||||
{onClose && (
|
||||
|
|
|
|||
14
src/app/(main)/boards/BoardsDataTable.tsx
Normal file
14
src/app/(main)/boards/BoardsDataTable.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { DataGrid } from '@/components/common/DataGrid';
|
||||
import { useBoardsQuery, useNavigation } from '@/components/hooks';
|
||||
import { BoardsTable } from './BoardsTable';
|
||||
|
||||
export function BoardsDataTable() {
|
||||
const { teamId } = useNavigation();
|
||||
const query = useBoardsQuery({ teamId });
|
||||
|
||||
return (
|
||||
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
|
||||
{({ data }) => <BoardsTable data={data} />}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,27 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { Column, IconLabel } from '@umami/react-zen';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { BoardAddButton } from './BoardAddButton';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Plus } from '@/components/icons';
|
||||
import { BoardsDataTable } from './BoardsDataTable';
|
||||
|
||||
export function BoardsPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<Column margin="2">
|
||||
<PageHeader title="My Boards">
|
||||
<BoardAddButton />
|
||||
<PageHeader title={formatMessage(labels.boards)}>
|
||||
<LinkButton href="/boards/create" variant="primary">
|
||||
<IconLabel icon={<Plus />} label={formatMessage(labels.addBoard)} />
|
||||
</LinkButton>
|
||||
</PageHeader>
|
||||
<Panel>
|
||||
<BoardsDataTable />
|
||||
</Panel>
|
||||
</Column>
|
||||
</PageBody>
|
||||
);
|
||||
|
|
|
|||
29
src/app/(main)/boards/BoardsTable.tsx
Normal file
29
src/app/(main)/boards/BoardsTable.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
|
||||
import Board from 'next/link';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||
|
||||
export function BoardsTable(props: DataTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { websiteId, renderUrl } = useNavigation();
|
||||
const { getSlugUrl } = useSlug('link');
|
||||
|
||||
return (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||
{({ id, name }: any) => {
|
||||
return <Board href={renderUrl(`/boards/${id}`)}>{name}</Board>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="description" label={formatMessage(labels.description)} />
|
||||
<DataColumn id="created" label={formatMessage(labels.created)} width="200px">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
{({ id, name }: any) => {
|
||||
return <Row></Row>;
|
||||
}}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Column, Heading } from '@umami/react-zen';
|
||||
|
||||
export function Board({ boardId }: { boardId: string }) {
|
||||
return (
|
||||
<Column>
|
||||
<Heading>Board title</Heading>
|
||||
<div>{boardId}</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
43
src/app/(main)/boards/[boardId]/BoardHeader.tsx
Normal file
43
src/app/(main)/boards/[boardId]/BoardHeader.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Button, Column, Grid, InlineEditField, Row } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function BoardHeader() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const defaultName = formatMessage(labels.untitled);
|
||||
const name = 'My Board';
|
||||
const description = 'This is my board';
|
||||
|
||||
const handleNameChange = (name: string) => {
|
||||
//updateReport({ name: name || defaultName });
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (description: string) => {
|
||||
//updateReport({ description });
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid columns="1fr 1fr">
|
||||
<Column>
|
||||
<Row>
|
||||
<InlineEditField
|
||||
name="name"
|
||||
value={name}
|
||||
placeholder={defaultName}
|
||||
onCommit={handleNameChange}
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<InlineEditField
|
||||
name="description"
|
||||
value={description}
|
||||
placeholder={`+ ${formatMessage(labels.addDescription)}`}
|
||||
onCommit={handleDescriptionChange}
|
||||
/>
|
||||
</Row>
|
||||
</Column>
|
||||
<Row justifyContent="flex-end">
|
||||
<Button variant="primary">{formatMessage(labels.save)}</Button>
|
||||
</Row>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
17
src/app/(main)/boards/[boardId]/BoardPage.tsx
Normal file
17
src/app/(main)/boards/[boardId]/BoardPage.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { BoardHeader } from '@/app/(main)/boards/[boardId]/BoardHeader';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function BoardPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<Column margin="2">
|
||||
<BoardHeader />
|
||||
</Column>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Board } from './Board';
|
||||
import { BoardPage } from './BoardPage';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
|
||||
const { boardId } = await params;
|
||||
|
||||
return <Board boardId={boardId} />;
|
||||
return <BoardPage boardId={boardId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
|
|||
78
src/app/api/boards/[boardId]/route.ts
Normal file
78
src/app/api/boards/[boardId]/route.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { z } from 'zod';
|
||||
import { SHARE_ID_REGEX } from '@/lib/constants';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
||||
import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions';
|
||||
import { deleteBoard, getBoard, updateBoard } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { boardId } = await params;
|
||||
|
||||
if (!(await canViewBoard(auth, boardId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const board = await getBoard(boardId);
|
||||
|
||||
return json(board);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
|
||||
const schema = z.object({
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { boardId } = await params;
|
||||
const { name, domain, shareId } = body;
|
||||
|
||||
if (!(await canUpdateBoard(auth, boardId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const board = await updateBoard(boardId, { name, domain, shareId });
|
||||
|
||||
return Response.json(board);
|
||||
} catch (e: any) {
|
||||
if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) {
|
||||
return badRequest({ message: 'That slug is already taken.' });
|
||||
}
|
||||
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ boardId: string }> },
|
||||
) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { boardId } = await params;
|
||||
|
||||
if (!(await canDeleteBoard(auth, boardId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
await deleteBoard(boardId);
|
||||
|
||||
return ok();
|
||||
}
|
||||
61
src/app/api/boards/route.ts
Normal file
61
src/app/api/boards/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
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 { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
|
||||
import { createBoard, getUserBoards } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query);
|
||||
|
||||
const boards = await getUserBoards(auth.user.id, filters);
|
||||
|
||||
return json(boards);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const schema = z.object({
|
||||
type: z.string(),
|
||||
name: z.string().max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
slug: z.string().max(100),
|
||||
userId: z.uuid().nullable().optional(),
|
||||
teamId: z.uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { teamId } = body;
|
||||
|
||||
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = {
|
||||
...body,
|
||||
id: uuid(),
|
||||
parameters: {},
|
||||
slug: uuid(),
|
||||
userId: !teamId ? auth.user.id : undefined,
|
||||
};
|
||||
|
||||
const result = await createBoard(data);
|
||||
|
||||
return json(result);
|
||||
}
|
||||
29
src/app/api/teams/[teamId]/boards/route.ts
Normal file
29
src/app/api/teams/[teamId]/boards/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { canViewTeam } from '@/permissions';
|
||||
import { getTeamPixels } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
});
|
||||
const { teamId } = await params;
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
if (!(await canViewTeam(auth, teamId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query);
|
||||
|
||||
const websites = await getTeamPixels(teamId, filters);
|
||||
|
||||
return json(websites);
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ export function DataGrid({
|
|||
</Row>
|
||||
)}
|
||||
<LoadingPanel
|
||||
data={data}
|
||||
data={data?.data}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export function LoadingPanel({
|
|||
}: LoadingPanelProps): ReactNode {
|
||||
const empty = isEmpty ?? checkEmpty(data);
|
||||
|
||||
console.log({ empty, isEmpty, data });
|
||||
|
||||
// Show loading spinner only if no data exists
|
||||
if (isLoading || isFetching) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export * from './context/useWebsite';
|
|||
|
||||
// Query hooks
|
||||
export * from './queries/useActiveUsersQuery';
|
||||
export * from './queries/useBoardsQuery';
|
||||
export * from './queries/useDateRangeQuery';
|
||||
export * from './queries/useDeleteQuery';
|
||||
export * from './queries/useEventDataEventsQuery';
|
||||
|
|
|
|||
17
src/components/hooks/queries/useBoardsQuery.ts
Normal file
17
src/components/hooks/queries/useBoardsQuery.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
import { usePagedQuery } from '../usePagedQuery';
|
||||
|
||||
export function useBoardsQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) {
|
||||
const { modified } = useModified('boards');
|
||||
const { get } = useApi();
|
||||
|
||||
return usePagedQuery({
|
||||
queryKey: ['boards', { teamId, modified }],
|
||||
queryFn: pageParams => {
|
||||
return get(teamId ? `/teams/${teamId}/boards` : '/boards', pageParams);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
64
src/permissions/board.ts
Normal file
64
src/permissions/board.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { hasPermission } from '@/lib/auth';
|
||||
import { PERMISSIONS } from '@/lib/constants';
|
||||
import type { Auth } from '@/lib/types';
|
||||
import { getBoard, getTeamUser } from '@/queries/prisma';
|
||||
|
||||
export async function canViewBoard({ user }: Auth, boardId: string) {
|
||||
if (user?.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const board = await getBoard(boardId);
|
||||
|
||||
if (board.userId) {
|
||||
return user.id === board.userId;
|
||||
}
|
||||
|
||||
if (board.teamId) {
|
||||
const teamUser = await getTeamUser(board.teamId, user.id);
|
||||
|
||||
return !!teamUser;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function canUpdateBoard({ user }: Auth, boardId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const board = await getBoard(boardId);
|
||||
|
||||
if (board.userId) {
|
||||
return user.id === board.userId;
|
||||
}
|
||||
|
||||
if (board.teamId) {
|
||||
const teamUser = await getTeamUser(board.teamId, user.id);
|
||||
|
||||
return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function canDeleteBoard({ user }: Auth, boardId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const board = await getBoard(boardId);
|
||||
|
||||
if (board.userId) {
|
||||
return user.id === board.userId;
|
||||
}
|
||||
|
||||
if (board.teamId) {
|
||||
const teamUser = await getTeamUser(board.teamId, user.id);
|
||||
|
||||
return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './board';
|
||||
export * from './link';
|
||||
export * from './pixel';
|
||||
export * from './report';
|
||||
|
|
|
|||
61
src/queries/prisma/board.ts
Normal file
61
src/queries/prisma/board.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { Prisma } from '@/generated/prisma/client';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
export async function findBoard(criteria: Prisma.BoardFindUniqueArgs) {
|
||||
return prisma.client.board.findUnique(criteria);
|
||||
}
|
||||
|
||||
export async function getBoard(boardId: string) {
|
||||
return findBoard({
|
||||
where: {
|
||||
id: boardId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBoards(criteria: Prisma.BoardFindManyArgs, filters: QueryFilters = {}) {
|
||||
const { search } = filters;
|
||||
const { getSearchParameters, pagedQuery } = prisma;
|
||||
|
||||
const where: Prisma.BoardWhereInput = {
|
||||
...criteria.where,
|
||||
...getSearchParameters(search, [{ name: 'contains' }, { description: 'contains' }]),
|
||||
};
|
||||
|
||||
return pagedQuery('board', { ...criteria, where }, filters);
|
||||
}
|
||||
|
||||
export async function getUserBoards(userId: string, filters?: QueryFilters) {
|
||||
return getBoards(
|
||||
{
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
filters,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTeamBoards(teamId: string, filters?: QueryFilters) {
|
||||
return getBoards(
|
||||
{
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
filters,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createBoard(data: Prisma.BoardUncheckedCreateInput) {
|
||||
return prisma.client.board.create({ data });
|
||||
}
|
||||
|
||||
export async function updateBoard(boardId: string, data: any) {
|
||||
return prisma.client.board.update({ where: { id: boardId }, data });
|
||||
}
|
||||
|
||||
export async function deleteBoard(boardId: string) {
|
||||
return prisma.client.board.delete({ where: { id: boardId } });
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './board';
|
||||
export * from './link';
|
||||
export * from './pixel';
|
||||
export * from './report';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue