Skip to content

Commit

Permalink
Merge pull request #4628 from bcgov/feat/3973
Browse files Browse the repository at this point in the history
Feat/3973 add event page
  • Loading branch information
Kolezhanchik authored Jan 9, 2025
2 parents e6d0d7d + 510ad5f commit 076e4b5
Show file tree
Hide file tree
Showing 16 changed files with 488 additions and 3 deletions.
32 changes: 32 additions & 0 deletions app/app/api/events/download/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { GlobalPermissions } from '@/constants';
import createApiHandler from '@/core/api-handler';
import { NoContent, CsvResponse } from '@/core/responses';
import { searchEvents } from '@/services/db';
import { formatDate } from '@/utils/js';
import { eventsSearchBodySchema } from '@/validation-schemas/event';

export const POST = createApiHandler({
permissions: [GlobalPermissions.ViewUsers],
validations: { body: eventsSearchBodySchema },
})(async ({ session, body }) => {
const searchProps = {
...body,
page: 1,
pageSize: 10000,
};

const { data, totalCount } = await searchEvents(searchProps);

if (data.length === 0) {
return NoContent();
}

const formattedData = data.map((event) => ({
Event: event.type,
'User email': event.user?.email,
Title: event.user?.jobTitle,
'Event created': formatDate(event.createdAt),
}));

return CsvResponse(formattedData, 'events.csv');
});
13 changes: 13 additions & 0 deletions app/app/api/events/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { GlobalPermissions } from '@/constants';
import createApiHandler from '@/core/api-handler';
import { OkResponse } from '@/core/responses';
import { searchEvents } from '@/services/db/event';
import { eventsSearchBodySchema } from '@/validation-schemas/event';

export const POST = createApiHandler({
permissions: [GlobalPermissions.ViewEvents],
validations: { body: eventsSearchBodySchema },
})(async ({ body }) => {
const result = await searchEvents(body);
return OkResponse(result);
});
54 changes: 54 additions & 0 deletions app/app/events/all/FilterPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Box, Button, LoadingOverlay } from '@mantine/core';
import { EventType } from '@prisma/client';
import { useSnapshot } from 'valtio';
import FormMultiSelect from '@/components/generic/select/FormMultiSelect';
import { eventTypeNames } from '@/constants/event';
import { pageState } from './state';

const eventTypeOptions = Object.entries(eventTypeNames).map(([key, value]) => ({
value: key,
label: value,
}));

export default function FilterPanel({ isLoading = false }: { isLoading?: boolean }) {
const pageSnapshot = useSnapshot(pageState);

return (
<Box pos={'relative'}>
<LoadingOverlay
visible={isLoading}
zIndex={1000}
overlayProps={{ radius: 'sm', blur: 2 }}
loaderProps={{ color: 'pink', type: 'bars' }}
/>
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-12 md:gap-x-3">
<div className="col-span-12">
<FormMultiSelect
name="roles"
label="Event types"
value={pageSnapshot.events ?? []}
data={eventTypeOptions}
onChange={(value) => {
pageState.events = value as EventType[];
pageState.page = 1;
}}
classNames={{ wrapper: '' }}
/>
<div className="text-right">
<Button
color="primary"
size="compact-md"
className="mt-1"
onClick={() => {
pageState.events = eventTypeOptions.map((option) => option.value as EventType);
pageState.page = 1;
}}
>
Select All
</Button>
</div>
</div>
</div>
</Box>
);
}
97 changes: 97 additions & 0 deletions app/app/events/all/TableBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import { Avatar, Badge, Group, Table, Text } from '@mantine/core';
import { EventType } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { eventTypeNames } from '@/constants/event';
import { formatFullName } from '@/helpers/user';
import { getUserImageData } from '@/helpers/user-image';
import { formatDate } from '@/utils/js';

interface User {
id: string;
firstName: string;
lastName: string;
email: string;
jobTitle: string;
image: string;
}

interface Event {
id: string;
type: EventType;
userId: string | null;
createdAt: string;
user: User | null;
}

interface TableProps {
data: Event[];
}

export default function TableBody({ data }: TableProps) {
const methods = useForm({
values: {
events: data,
},
});

const [events] = methods.watch(['events']);

const rows = events.length ? (
events.map((event, index) => (
<Table.Tr key={event.id ?? index}>
<Table.Td>
<Text size="xs">{eventTypeNames[event.type]}</Text>
</Table.Td>
<Table.Td>
<Group gap="sm" className="cursor-pointer" onClick={async () => {}}>
<Avatar src={getUserImageData(event.user?.image)} size={36} radius="xl" />
<div>
<Text size="sm" className="font-semibold">
{formatFullName(event.user)}
</Text>
<Text size="xs" opacity={0.5}>
{event.user?.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
{event.user?.jobTitle && (
<div>
<Badge color="info" variant="filled">
{event.user?.jobTitle}
</Badge>
</div>
)}
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(event.createdAt)}</Text>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={5} className="italic">
No events found
</Table.Td>
</Table.Tr>
);

return (
<Table.ScrollContainer minWidth={800}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Event Type</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Position</Table.Th>
<Table.Th>Date</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
5 changes: 5 additions & 0 deletions app/app/events/all/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use client';

export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
64 changes: 64 additions & 0 deletions app/app/events/all/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client';

import { useQuery } from '@tanstack/react-query';
import { useSnapshot } from 'valtio/react';
import Table from '@/components/generic/table/Table';
import { GlobalPermissions } from '@/constants';
import createClientPage from '@/core/client-page';
import { downloadEvents, searchEvents } from '@/services/backend/events';
import FilterPanel from './FilterPanel';
import { eventSorts, pageState } from './state';
import TableBody from './TableBody';

const eventsPage = createClientPage({
permissions: [GlobalPermissions.ViewEvents],
fallbackUrl: '/login?callbackUrl=/home',
});

export default eventsPage(() => {
const snap = useSnapshot(pageState);
let totalCount = 0;
let events = [];

const { data, isLoading } = useQuery({
queryKey: ['events', snap],
queryFn: () => searchEvents(snap),
});

if (!isLoading && data) {
events = data.data;
totalCount = data.totalCount;
}
return (
<>
<Table
title="Events in Registry"
totalCount={totalCount}
page={snap.page}
pageSize={snap.pageSize}
sortKey={snap.sortValue}
onPagination={(page: number, pageSize: number) => {
pageState.page = page;
pageState.pageSize = pageSize;
}}
onSearch={(searchTerm: string) => {
pageState.page = 1;
pageState.search = searchTerm;
}}
onExport={async () => {
const result = await downloadEvents(snap);
return result;
}}
onSort={(sortValue) => {
pageState.page = 1;
pageState.sortValue = sortValue;
}}
sortOptions={eventSorts.map((val) => val.label)}
filters={<FilterPanel />}
isLoading={isLoading}
>
<TableBody data={events} />
</Table>
</>
);
});
35 changes: 35 additions & 0 deletions app/app/events/all/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { EventType, Prisma } from '@prisma/client';
import { proxy } from 'valtio';

export const eventSorts = [
{
label: 'Event date (new to old)',
sortKey: 'createdAt',
sortOrder: Prisma.SortOrder.desc,
},
{
label: 'Event date (old to new)',
sortKey: 'createdAt',
sortOrder: Prisma.SortOrder.asc,
},
];

type PageState = {
page: number;
pageSize: number;
search: string;
events: EventType[];
sortValue: string;
sortKey: string;
sortOrder: 'asc' | 'desc';
};

export const pageState = proxy<PageState>({
page: 1,
pageSize: 10,
search: '',
events: [],
sortValue: eventSorts[0].label,
sortKey: eventSorts[0].sortKey,
sortOrder: eventSorts[0].sortOrder,
});
9 changes: 8 additions & 1 deletion app/components/layouts/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import {
IconLogout,
IconProps,
Icon,
IconCalendarEvent,
} from '@tabler/icons-react';
import Link from 'next/link';
import { Permissions } from 'next-auth';
import { useSession } from 'next-auth/react';
import { useState, ForwardRefExoticComponent, RefAttributes } from 'react';
import { ForwardRefExoticComponent, RefAttributes } from 'react';
import { openUserProfileModal } from '@/components/modal/userProfile';
import { signOut } from '@/helpers/auth';
import { useAppState } from '@/states/global';
Expand Down Expand Up @@ -114,6 +115,12 @@ export default function UserMenu() {
href: '/sonarscan/results',
permission: 'viewSonarscanResults',
},
{
text: 'Events',
Icon: IconCalendarEvent,
href: '/events/all',
permission: 'viewEvents',
},
{ divider: true, key: '1' },
{
text: 'Sign Out',
Expand Down
23 changes: 23 additions & 0 deletions app/constants/event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { EventType } from '@prisma/client';

export const eventTypeNames: Record<EventType, string> = {
[EventType.LOGIN]: 'Login',
[EventType.LOGOUT]: 'Logout',
[EventType.CREATE_TEAM_API_TOKEN]: 'Create Team API Token',
[EventType.UPDATE_TEAM_API_TOKEN]: 'Update Team API Token',
[EventType.DELETE_TEAM_API_TOKEN]: 'Delete Team API Token',
[EventType.CREATE_API_TOKEN]: 'Create API Token',
[EventType.DELETE_API_TOKEN]: 'Delete API Token',
[EventType.CREATE_PRIVATE_CLOUD_PRODUCT]: 'Create Private Cloud Product',
[EventType.UPDATE_PRIVATE_CLOUD_PRODUCT]: 'Update Private Cloud Product',
[EventType.DELETE_PRIVATE_CLOUD_PRODUCT]: 'Delete Private Cloud Product',
[EventType.EXPORT_PRIVATE_CLOUD_PRODUCT]: 'Export Private Cloud Product',
[EventType.REVIEW_PRIVATE_CLOUD_REQUEST]: 'Review Private Cloud Request',
[EventType.RESEND_PRIVATE_CLOUD_REQUEST]: 'Resend Private Cloud Request',
[EventType.REPROVISION_PRIVATE_CLOUD_PRODUCT]: 'Reprovision Private Cloud Product',
[EventType.CREATE_PUBLIC_CLOUD_PRODUCT]: 'Create Public Cloud Product',
[EventType.UPDATE_PUBLIC_CLOUD_PRODUCT]: 'Update Public Cloud Product',
[EventType.DELETE_PUBLIC_CLOUD_PRODUCT]: 'Delete Public Cloud Product',
[EventType.EXPORT_PUBLIC_CLOUD_PRODUCT]: 'Export Public Cloud Product',
[EventType.REVIEW_PUBLIC_CLOUD_REQUEST]: 'Review Public Cloud Request',
};
3 changes: 3 additions & 0 deletions app/constants/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export enum GlobalPermissions {

DownloadBillingMou = 'downloadBillingMou',

ViewEvents = 'viewEvents',
ViewUsers = 'viewUsers',
EditUsers = 'editUsers',
}
Expand All @@ -58,6 +59,7 @@ export enum GlobalRole {
Approver = 'approver',
BillingReviewer = 'billing-reviewer',
Billingreader = 'billing-reader',
EventReader = 'event-reader',
}

export const RoleToSessionProp = {
Expand All @@ -78,6 +80,7 @@ export const RoleToSessionProp = {
[GlobalRole.PublicReader]: 'isPublicReader',
[GlobalRole.PublicReviewer]: 'isPublicReviewer',
[GlobalRole.UserReader]: 'isUserReader',
[GlobalRole.EventReader]: 'isEventReader',
[GlobalRole.Approver]: 'isApprover',
[GlobalRole.BillingReviewer]: 'isBillingReviewer',
[GlobalRole.Billingreader]: 'isBillingReader',
Expand Down
1 change: 1 addition & 0 deletions app/core/auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ export async function generateSession({

downloadBillingMou: session.isBillingReviewer || session.isBillingReader,
viewUsers: session.isAdmin || session.isUserReader,
viewEvents: session.isAdmin || session.isEventReader,
editUsers: session.isAdmin,
};

Expand Down
Loading

0 comments on commit 076e4b5

Please sign in to comment.