Skip to content

Commit

Permalink
Merge pull request #455 from commons-stack/enh/user_event_logs
Browse files Browse the repository at this point in the history
User Event Logs - filter, search and sorting
  • Loading branch information
kristoferlund authored Jun 15, 2022
2 parents 28a57b2 + b52b051 commit 5dac5fb
Show file tree
Hide file tree
Showing 15 changed files with 525 additions and 104 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Filter, search and sort the event log #376 #455
- Dark mode! #420 #453
- Support multiple wallets: WalletConnect, Trust, Rainbow etc #424
- Export the user list as csv #402 #450
Expand Down
48 changes: 43 additions & 5 deletions packages/api/src/eventlog/controllers.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
import { getQuerySort } from '@shared/functions';
import {
EventLogsQueryInputParsedQs,
PaginatedResponseBody,
QueryInputParsedQs,
TypedRequestQuery,
TypedResponse,
} from '@shared/types';
import { BadRequestError } from '@error/errors';
import { StatusCodes } from 'http-status-codes';
import { EventLogModel } from './entities';
import { EventLogDto } from './types';
import { eventLogListTransformer } from './transformers';
import { EventLogModel, EventLogTypeModel } from './entities';
import { EventLogDto, EventLogInput, EventLogTypeDto } from './types';
import {
eventLogListTransformer,
eventLogTypeListTransformer,
} from './transformers';
import mongoose from 'mongoose';

/**
* Fetch a paginated list of EventLogs
*/
export const all = async (
req: TypedRequestQuery<QueryInputParsedQs>,
req: TypedRequestQuery<EventLogsQueryInputParsedQs>,
res: TypedResponse<PaginatedResponseBody<EventLogDto>>
): Promise<void> => {
if (!req.query.limit || !req.query.page)
throw new BadRequestError('limit and page are required');

const query: EventLogInput = {};
if (req.query.type) {
const typesArray = req.query.type.split(',');
const types = await EventLogTypeModel.find({ key: { $in: typesArray } });
query.type = types.map((item) => new mongoose.Types.ObjectId(item.id));
}

if (req.query.search && req.query.search !== '') {
query.description = {
$regex: `${req.query.search.toString()}`,
$options: 'i',
};
}

const paginateQuery = {
query: {},
query,
limit: parseInt(req.query.limit),
page: parseInt(req.query.page),
sort: getQuerySort(req.query),
Expand All @@ -43,3 +62,22 @@ export const all = async (
docs: docsTransfomed,
});
};

/**
* Fetch a list of EventLogsTypes
*/
export const types = async (
req: TypedRequestQuery<QueryInputParsedQs>,
res: TypedResponse<PaginatedResponseBody<EventLogTypeDto>>
): Promise<void> => {
const response = await EventLogTypeModel.find();

if (!response) throw new BadRequestError('Failed to query event log types');

const docsTransfomed = await eventLogTypeListTransformer(response);

res.status(StatusCodes.OK).json({
...response,
docs: docsTransfomed,
});
};
1 change: 1 addition & 0 deletions packages/api/src/eventlog/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ import * as controller from './controllers';
const eventLogRouter = Router();

eventLogRouter.getAsync('/all', controller.all);
eventLogRouter.getAsync('/types', controller.types);

export { eventLogRouter };
10 changes: 10 additions & 0 deletions packages/api/src/eventlog/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ const eventLogTypeTransformer = (
} as EventLogTypeDto;
};

export const eventLogTypeListTransformer = async (
eventLogTypes: EventLogTypeDocument[]
): Promise<EventLogTypeDto[]> => {
const eventLogTypeDtos = await Promise.all(
eventLogTypes.map((eventLogType) => eventLogTypeTransformer(eventLogType))
);

return eventLogTypeDtos;
};

export const eventLogTransformer = async (
eventLog: EventLogDocument,
currentUserRoles: UserRole[] = [UserRole.USER]
Expand Down
5 changes: 5 additions & 0 deletions packages/api/src/eventlog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ export interface EventLogTypeDto {
label: string;
description: string;
}

export interface EventLogInput {
type?: Types.ObjectId[];
description?: Object;
}
9 changes: 9 additions & 0 deletions packages/api/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ export interface SearchQueryInput extends QueryInput {

export interface SearchQueryInputParsedQs extends SearchQueryInput, Query {}

export interface EventLogsQueryInput extends QueryInput {
search?: string;
type?: string;
}

export interface EventLogsQueryInputParsedQs
extends EventLogsQueryInput,
Query {}

export interface TypedRequest<T extends Query, U> extends Request {
body: U;
query: T;
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
<meta property="twitter:image" content="%PUBLIC_URL%/Share.png" />
<title>Praise</title>
</head>
<body class="text-sm">
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
Expand Down
94 changes: 94 additions & 0 deletions packages/frontend/src/components/form/MultiselectInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Listbox, Transition } from '@headlessui/react';
import { Fragment } from 'react';

interface ISelectedItem {
key: string;
label: string;
}

interface MultiselectInputProps {
selected: ISelectedItem[];
handleChange: (element) => void;
options: ISelectedItem[];
noSelectedMessage: string;
}

const MultiselectInput = ({
handleChange,
selected,
options,
noSelectedMessage,
}: MultiselectInputProps): JSX.Element => {
return (
<div className="relative w-60 border border-gray-400 h-[42px]">
<Listbox value={selected} onChange={handleChange} multiple>
<Listbox.Button className=" pl-2 pr-8 text-left h-[42px] w-full bg-transparent border-none outline-none focus:ring-0 ">
{(): JSX.Element => (
<>
<span className="block truncate">
{selected.length === 0 && noSelectedMessage}
{selected.length > 3
? `${selected.length} items.`
: selected
.map((filter) =>
filter && filter.label ? filter.label : ''
)
.join(', ')}
</span>

<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<span className="text-gray-800">
<FontAwesomeIcon icon={faChevronDown} className="mt-[-1]" />
</span>
</div>
</>
)}
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto bg-white border border-gray-400 max-h-60">
{options.map((filter, filterIdx) => (
<Listbox.Option
key={filterIdx}
className={({ active }): string =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-gray-100 text-gray-900' : 'text-gray-600'
}`
}
value={filter}
>
{({ selected }): JSX.Element => (
<>
<span
className={`block truncate ${
selected ? 'font-medium' : 'font-normal'
}`}
>
{filter.label}
</span>
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-600">
<input
type="checkbox"
className="w-4 h-4 mr-4 text-black focus:ring-0"
checked={selected}
onChange={(): void => {}}
/>
</span>
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</Listbox>
</div>
);
};

export default MultiselectInput;
38 changes: 38 additions & 0 deletions packages/frontend/src/components/form/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

interface SearchInputProps {
handleChange: (element) => void;
value: string;
}

const SearchInput = ({
handleChange,
value,
}: SearchInputProps): JSX.Element => {
return (
<div className="relative flex items-center border border-gray-400 h-[42px]">
<div className="absolute inset-y-0 left-0 flex items-center pl-3">
<span className="text-gray-800">
<FontAwesomeIcon
icon={faMagnifyingGlass}
size="1x"
className="absolute transform -translate-y-1/2 top-1/2 left-3"
/>
</span>
</div>
<input
className="h-[42px] block pl-8 bg-transparent border-none outline-none focus:ring-0"
name="search"
type="text"
placeholder="Search"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
handleChange(e.target.value)
}
/>
</div>
);
};

export default SearchInput;
71 changes: 71 additions & 0 deletions packages/frontend/src/components/form/SelectInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Listbox, Transition } from '@headlessui/react';
import { Fragment } from 'react';

interface ISelectedItem {
value: string;
label: string;
}

interface SelectInputProps {
selected: ISelectedItem;
handleChange: (element) => void;
options: ISelectedItem[];
icon?: IconProp;
}

const SelectInput = ({
selected,
handleChange,
options,
icon,
}: SelectInputProps): JSX.Element => {
return (
<div className="relative w-40 h-[42px]">
<Listbox value={selected} onChange={handleChange}>
<Listbox.Button className="h-[42px] border border-gray-400 w-full py-1.5 pl-3 pr-10 text-left bg-transparent ">
<span className="block truncate">{selected.label}</span>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<span className="text-gray-800 ">
<FontAwesomeIcon icon={icon || faChevronDown} />
</span>
</div>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto bg-white border border-gray-400 max-h-60">
{options.map((s, sIdx) => (
<Listbox.Option
key={sIdx}
className={({ active }): string =>
`relative cursor-default select-none py-2 pl-4 pr-4 ${
active ? 'bg-gray-100 text-gray-900' : 'text-gray-600'
}`
}
value={s}
>
{({ selected }): JSX.Element => (
<span
className={`block truncate ${
selected ? 'font-medium' : 'font-normal'
}`}
>
{s.label}
</span>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</Listbox>
</div>
);
};

export default SelectInput;
Loading

0 comments on commit 5dac5fb

Please sign in to comment.