Skip to content

Commit

Permalink
feat: add infinite scroll and pagination to notificationspopover gf-4…
Browse files Browse the repository at this point in the history
…58 (#490)

* feat: added pagination for notifications to enable use of infinite scroll gf-455

* fix: adjusted css and imports in notifications-popover gf-455

* fix: adjusted misspelling and removed comments gf-455

* fix: notification action type correction gf-455

* fix: adjusted backend to reflect current project style better gf-455

* fix: adjusted merge conflict gf-458

* fix: adjusted notification title to be sticky gf-458

* fix: added separate file for constant, adjusted use of enum gf-458

* fix: adjusted swagger to reflect backend code gf-458

* fix: adjusted naming and default value gf-248

* fix: adjusted controller to use proper type and removed variable gf-458

---------

Co-authored-by: Vitalii Stefaniv <[email protected]>
  • Loading branch information
Fjortis and Vitaliistf authored Sep 24, 2024
1 parent 47d8620 commit 9daf38b
Show file tree
Hide file tree
Showing 18 changed files with 147 additions and 38 deletions.
2 changes: 2 additions & 0 deletions apps/backend/src/modules/notifications/libs/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ export {
type NotificationBulkCreateResponseDto,
type NotificationCreateRequestDto,
type NotificationGetAllItemResponseDto,
type NotificationGetAllRequestDto,
type NotificationGetAllResponseDto,
type UserAuthResponseDto,
} from "@git-fit/shared";
46 changes: 37 additions & 9 deletions apps/backend/src/modules/notifications/notification.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { HTTPCode } from "~/libs/modules/http/http.js";
import { type Logger } from "~/libs/modules/logger/logger.js";

import { NotificationsApiPath } from "./libs/enums/enums.js";
import {
type NotificationGetAllRequestDto,
type UserAuthResponseDto,
} from "./libs/types/types.js";
import { type NotificationService } from "./notification.service.js";

/**
Expand Down Expand Up @@ -43,7 +47,12 @@ class NotificationController extends BaseController {
this.notificationService = notificationService;

this.addRoute({
handler: (options) => this.findAll(options),
handler: (options) =>
this.findAll(
options as APIHandlerOptions<{
query: NotificationGetAllRequestDto;
}>,
),
method: "GET",
path: NotificationsApiPath.ROOT,
});
Expand All @@ -53,7 +62,18 @@ class NotificationController extends BaseController {
* @swagger
* /notifications:
* get:
* description: Returns an array of notifications
* description: Returns an array of notifications with pagination
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* description: The page number to retrieve
* - in: query
* name: pageSize
* schema:
* type: integer
* description: The number of items per page
* responses:
* 200:
* description: Successful operation
Expand All @@ -66,19 +86,27 @@ class NotificationController extends BaseController {
* type: array
* items:
* $ref: "#/components/schemas/Notification"
* totalItems:
* type: integer
* description: The total number of notifications
*/
private async findAll(
options: APIHandlerOptions,
): Promise<APIHandlerResponse> {
const { user } = options;

const typedUser = user as { id: number };
private async findAll({
query,
user,
}: APIHandlerOptions<{
query: NotificationGetAllRequestDto;
}>): Promise<APIHandlerResponse> {
const { page, pageSize } = query;

return {
payload: await this.notificationService.findAll(typedUser.id),
payload: await this.notificationService.findAll({
page,
pageSize,
userId: (user as UserAuthResponseDto).id,
}),
status: HTTPCode.OK,
};
}
}

export { NotificationController };
22 changes: 16 additions & 6 deletions apps/backend/src/modules/notifications/notification.repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { SortType } from "~/libs/enums/enums.js";
import { type Repository } from "~/libs/types/types.js";
import {
type PaginationResponseDto,
type Repository,
} from "~/libs/types/types.js";

import { type NotificationGetAllRequestDto } from "./libs/types/types.js";
import { NotificationEntity } from "./notification.entity.js";
import { type NotificationModel } from "./notification.model.js";

Expand Down Expand Up @@ -56,19 +60,25 @@ class NotificationRepository implements Repository {
return Promise.resolve(null);
}

public async findAll(
userId: number,
): Promise<{ items: NotificationEntity[] }> {
const notifications = await this.notificationModel
public async findAll({
page,
pageSize,
userId,
}: NotificationGetAllRequestDto): Promise<
PaginationResponseDto<NotificationEntity>
> {
const { results, total } = await this.notificationModel
.query()
.where("receiverUserId", userId)
.orderBy("created_at", SortType.DESCENDING)
.page(page, pageSize)
.execute();

return {
items: notifications.map((notification) =>
items: results.map((notification) =>
NotificationEntity.initialize(notification),
),
totalItems: total,
};
}

Expand Down
10 changes: 7 additions & 3 deletions apps/backend/src/modules/notifications/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type NotificationBulkCreateResponseDto,
type NotificationCreateRequestDto,
type NotificationGetAllItemResponseDto,
type NotificationGetAllRequestDto,
type NotificationGetAllResponseDto,
} from "./libs/types/types.js";
import { NotificationEntity } from "./notification.entity.js";
Expand Down Expand Up @@ -60,11 +61,14 @@ class NotificationService implements Service {
return Promise.resolve(null);
}

public async findAll(userId: number): Promise<NotificationGetAllResponseDto> {
const result = await this.notificationRepository.findAll(userId);
public async findAll(
parameters: NotificationGetAllRequestDto,
): Promise<NotificationGetAllResponseDto> {
const notifications = await this.notificationRepository.findAll(parameters);

return {
items: result.items.map((item) => item.toObject()),
items: notifications.items.map((item) => item.toObject()),
totalItems: notifications.totalItems,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NOTIFICATIONS_PAGE_SIZE } from "./notifications-page-size.constant.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const NOTIFICATIONS_PAGE_SIZE = 10;

export { NOTIFICATIONS_PAGE_SIZE };
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { Popover } from "~/libs/components/components.js";
import { Loader, Popover } from "~/libs/components/components.js";
import { EMPTY_LENGTH } from "~/libs/constants/constants.js";
import { DataStatus } from "~/libs/enums/enums.js";
import { formatRelativeTime } from "~/libs/helpers/helpers.js";
import {
useAppDispatch,
useAppSelector,
useCallback,
useEffect,
useInfiniteScroll,
useIntersectionObserver,
} from "~/libs/hooks/hooks.js";
import { actions as notificationActions } from "~/modules/notifications/notifications.js";

import { NotificationItem } from "./libs/components/components.js";
import { NOTIFICATIONS_PAGE_SIZE } from "./libs/constants/constants.js";
import styles from "./styles.module.css";

type Properties = {
Expand All @@ -24,20 +28,38 @@ const NotificationsPopover = ({
onClose,
}: Properties): JSX.Element => {
const dispatch = useAppDispatch();

const { notifications } = useAppSelector(
const { dataStatus, notifications, notificationsTotalCount } = useAppSelector(
({ notifications }) => notifications,
);

const handleLoadNotifications = useCallback(() => {
void dispatch(notificationActions.loadAll());
}, [dispatch]);
const handleLoadNotifications = useCallback(
(page: number, pageSize: number) => {
void dispatch(notificationActions.loadAll({ page, pageSize }));
},
[dispatch],
);

const { hasNextPage, onNextPage, onPageReset } = useInfiniteScroll({
currentItemsCount: notifications.length,
onLoading: handleLoadNotifications,
pageSize: NOTIFICATIONS_PAGE_SIZE,
totalItemsCount: notificationsTotalCount,
});

const { reference: sentinelReference } =
useIntersectionObserver<HTMLDivElement>({
isDisabled: !hasNextPage || dataStatus === DataStatus.PENDING,
onIntersect: onNextPage,
});

useEffect(() => {
handleLoadNotifications();
}, [handleLoadNotifications]);
if (isOpened) {
onPageReset();
}
}, [isOpened, onPageReset]);

const hasNotifications = notifications.length !== EMPTY_LENGTH;
const isLoadingMore = hasNextPage && dataStatus === DataStatus.PENDING;

return (
<Popover
Expand All @@ -58,6 +80,10 @@ const NotificationsPopover = ({
There is nothing yet.
</p>
)}

<div className={styles["sentinel"]} ref={sentinelReference} />

{isLoadingMore && <Loader />}
</div>
</div>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
.notifications-popover {
min-width: 380px;
max-height: 360px;
overflow-y: auto;
}

.notifications-title {
position: sticky;
top: 0;
padding: 14px 16px;
margin: 0;
font-size: 16px;
font-weight: 500;
line-height: 1.2;
color: var(--color-text-primary);
text-align: left;
background-color: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border-secondary);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
type NotificationGetAllItemResponseDto,
type NotificationGetAllRequestDto,
type NotificationGetAllResponseDto,
} from "@git-fit/shared";
9 changes: 8 additions & 1 deletion apps/frontend/src/modules/notifications/notifications-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { APIPath, ContentType } from "~/libs/enums/enums.js";
import { BaseHTTPApi } from "~/libs/modules/api/api.js";
import { type HTTP } from "~/libs/modules/http/http.js";
import { type Storage } from "~/libs/modules/storage/storage.js";
import { type PaginationQueryParameters } from "~/libs/types/types.js";

import { NotificationsApiPath } from "./libs/enums/enums.js";
import { type NotificationGetAllResponseDto } from "./libs/types/types.js";
Expand All @@ -17,13 +18,19 @@ class NotificationApi extends BaseHTTPApi {
super({ baseUrl, http, path: APIPath.NOTIFICATIONS, storage });
}

public async getAll(): Promise<NotificationGetAllResponseDto> {
public async getAll(
query: PaginationQueryParameters,
): Promise<NotificationGetAllResponseDto> {
const response = await this.load(
this.getFullEndpoint(NotificationsApiPath.ROOT, {}),
{
contentType: ContentType.JSON,
hasAuth: true,
method: "GET",
query: {
page: String(query.page),
pageSize: String(query.pageSize),
},
},
);

Expand Down
11 changes: 7 additions & 4 deletions apps/frontend/src/modules/notifications/slices/actions.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { createAsyncThunk } from "@reduxjs/toolkit";

import { type AsyncThunkConfig } from "~/libs/types/types.js";
import {
type AsyncThunkConfig,
type PaginationQueryParameters,
} from "~/libs/types/types.js";

import { type NotificationGetAllResponseDto } from "../libs/types/types.js";
import { name as sliceName } from "./notification.slice.js";

const loadAll = createAsyncThunk<
NotificationGetAllResponseDto,
undefined,
PaginationQueryParameters,
AsyncThunkConfig
>(`${sliceName}/load-all`, async (_, { extra }) => {
>(`${sliceName}/load-all`, async (query, { extra }) => {
const { notificationApi } = extra;

return await notificationApi.getAll();
return await notificationApi.getAll(query);
});

export { loadAll };
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ import { createSlice } from "@reduxjs/toolkit";

import { DataStatus } from "~/libs/enums/enums.js";
import { type ValueOf } from "~/libs/types/types.js";
import { FIRST_PAGE } from "~/modules/projects/libs/constants/constants.js";

import { type NotificationGetAllItemResponseDto } from "../libs/types/types.js";
import { loadAll } from "./actions.js";

type State = {
dataStatus: ValueOf<typeof DataStatus>;
notifications: NotificationGetAllItemResponseDto[];
notificationsTotalCount: number;
};

const initialState: State = {
dataStatus: DataStatus.IDLE,
notifications: [],
notificationsTotalCount: 0,
};

const { actions, name, reducer } = createSlice({
Expand All @@ -22,11 +25,17 @@ const { actions, name, reducer } = createSlice({
state.dataStatus = DataStatus.PENDING;
});
builder.addCase(loadAll.fulfilled, (state, action) => {
state.notifications = action.payload.items;
const { items, totalItems } = action.payload;
const { page } = action.meta.arg;

state.notifications =
page === FIRST_PAGE ? items : [...state.notifications, ...items];
state.notificationsTotalCount = totalItems;
state.dataStatus = DataStatus.FULFILLED;
});
builder.addCase(loadAll.rejected, (state) => {
state.notifications = [];
state.notificationsTotalCount = initialState.notificationsTotalCount;
state.dataStatus = DataStatus.REJECTED;
});
},
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export {
type NotificationBulkCreateResponseDto,
type NotificationCreateRequestDto,
type NotificationGetAllItemResponseDto,
type NotificationGetAllRequestDto,
type NotificationGetAllResponseDto,
NotificationsApiPath,
} from "./modules/notifications/notifications.js";
Expand Down
Loading

0 comments on commit 9daf38b

Please sign in to comment.