Repository: dream-aim-deliver/dad-e-class-monorepo
Pull request #167: [TSK-919] Grids - Notification and User
Author: login: MytsV
Labels: name: ui-kit
Created: 2025-04-24T11:42:20Z | Merged: 2025-04-30T12:57:04Z

# Summary

This PR features implementaton of BaseGrid with a custom pagination panel, NotificationGrid and UserGrid with an attempt at styling. I haven't participated in any discussions on Figma, therefore I tried to match it visually. I left TODO comments specifying places where styling has to be checked.

# Notification Grid

I haven't noticed existence of notification card component, therefore I implemented from scratch. However, I checked the implementation and noticed quite significant defects with no testing for overflow and no layout breakpoints as per Figma. Please see this [video comparison](https://drive.google.com/file/d/1DKsgt5ctJ1EFdSH3OyBo327tuVd23eDv/view?usp=sharing). Therefore, a decision has to be made on how to merge those.

A lot of issues are coming from the fact that AG Grid poorly supports rows having height set automatically. This is a known bug described [here](https://stackoverflow.com/questions/73560068/ag-grid-autoheight-true-coldef-property-on-cell-renderer-causes-stutter). Please see this [demo of the bug](https://drive.google.com/file/d/1dVrQEyHWAMEoCOg2LkG7BfRXJVwQd66F/view?usp=sharing). Currently I added crutches of [showing a placeholder skeleton](https://drive.google.com/file/d/19jbi7YlFPv0r6cWdxr7ea8t-B8euXTz3/view?usp=sharing) to cover this ugly issue.

However, I see a lot of benefit of actually dropping the AG Grid for this grid. It shouldn't be hard to implement basic pagination without it. We don't need any filters or sorting there after all.

# User Grid

This one doesn't suffer from the bug.

The filtering will be external, and the logic for it would lie outside the component. It could be set either with gridApi or passing doesExternalFilterPass. When the component is selectable, the selected rows could be fetched through gridRef. There are examples documented in the stories.

# Points for discussion

- Default sorting icons in AG Grid look like arrows, not chevrons. It is quite complicated to change, but possible. Should they be changed according to Figma?

![image](https://github.com/user-attachments/assets/68e1b88f-e489-4975-afc4-818c27dfedae)

- Default checkboxes in AG Grid differ from Figma and look more like standard HTML inputs, but are, in all honesty, quite nice fitting. It won't be too hard to change them. Should they be changed?

![image](https://github.com/user-attachments/assets/d8a0c062-6415-4360-9a4e-758918837a4c)

# Follow-ups

- AG Grid is styled in the CSS file. I tried to match the Figma visually, but it should be 100%. To see possible theming variables, use the [Theme Builder](https://www.ag-grid.com/theme-builder/). For example, Cell Editing Border will correspond to --ag-cell-editing-border variable.
- Scrollbars should be globally set to dark mode
![image](https://github.com/user-attachments/assets/6b6470af-d642-416c-bbf3-20ec8245b132)
- There should be a shimmering skeleton to cover the grids while they load. Currently I made some mock which displays a lighter background. It should be implemented for other components (see shadcn Skeleton).
- There are no indications on styling tables with no data or in a loading state. These should be considered and styled properly and consistently.


Diff:
diff --git a/packages/ui-kit/lib/assets/css/tailwind.css b/packages/ui-kit/lib/assets/css/tailwind.css
index 1c93e12e7..2ec984df0 100644
--- a/packages/ui-kit/lib/assets/css/tailwind.css
+++ b/packages/ui-kit/lib/assets/css/tailwind.css
@@ -146,6 +146,14 @@
 
   --color-tooltip-background: var(--tooltip-background);
   --color-tooltip-border: var(--tooltip-border);
+
+  /* AG Grid */
+  --ag-header-row-border: none;
+  /* TODO: ensure styling matching Figma */
+  --ag-background-color: var(--color-base-neutral-900);
+  --ag-foreground-color: var(--color-base-neutral-50);
+  --ag-header-background-color: var(--color-base-neutral-900);
+  --ag-accent-color: var(--color-base-brand-500);
 }
 
 @layer base {
@@ -293,6 +301,7 @@
   --font-weight-h3: var(--font-weight-important);
   --font-weight-h5: var(--font-weight-important);
   font-family: var(--font-nunito);
+  --ag-font-family: var(--font-nunito);
 }
 
 .theme-Job-rand-me {
@@ -415,6 +424,7 @@
   --font-weight-h3: var(--font-weight-important);
   --font-weight-h5: var(--font-weight-important);
   font-family: var(--font-roboto);
+  --ag-font-family: var(--font-roboto);
 }
 
 .theme-Bewerbeagentur {
@@ -537,6 +547,7 @@
   --font-weight-h3: 400;
   --font-weight-h5: 400;
   font-family: var(--font-raleway);
+  --ag-font-family: var(--font-raleway);
 }
 
 .theme-Cms {
@@ -654,4 +665,11 @@
   --font-weight-h3: var(--font-weight-important);
   --font-weight-h5: var(--font-weight-important);
   font-family: var(--font-figtree);
+  --ag-font-family: var(--font-figtree);
+}
+
+.ag-root-wrapper {
+  padding: 0 0.75rem; /* Horizontal padding for cells which includes their borders */
+  border-bottom-left-radius: 0!important; /* No rounding of the bottom border for pagination panel integration */
+  border-bottom-right-radius: 0!important;
 }
diff --git a/packages/ui-kit/lib/components/grids/base-grid.tsx b/packages/ui-kit/lib/components/grids/base-grid.tsx
new file mode 100644
index 000000000..f3ab620a2
--- /dev/null
+++ b/packages/ui-kit/lib/components/grids/base-grid.tsx
@@ -0,0 +1,161 @@
+import { RefObject, useEffect, useRef, useState } from 'react';
+import { cn } from '../../utils/style-utils';
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
+import { AgGridReact, AgGridReactProps } from 'ag-grid-react';
+import { GridReadyEvent } from 'ag-grid-community';
+
+export interface BaseTableProps extends AgGridReactProps {
+    gridRef: RefObject<AgGridReact | null>;
+}
+
+/**
+ * A component for flexible and responsive pagination of the table
+ */
+export const SimplePaginationPanel = (props: {
+    currentPageRef: RefObject<HTMLSpanElement | null>;
+    totalPagesRef: RefObject<HTMLSpanElement | null>;
+    previousPageRef: RefObject<HTMLButtonElement | null>;
+    nextPageRef: RefObject<HTMLButtonElement | null>;
+    lastPageRef: RefObject<HTMLButtonElement | null>;
+    firstPageRef: RefObject<HTMLButtonElement | null>;
+    containerRef: RefObject<HTMLDivElement | null>;
+}) => {
+    const enabledTextClasses = 'text-neutral-50';
+    const disabledTextClasses = 'disabled:text-neutral-500';
+    const buttonClasses = cn('text-l cursor-pointer', 'px-1', enabledTextClasses, disabledTextClasses);
+    const iconClasses = 'h-5 w-5';
+
+    return (
+        <div
+            className={cn(
+                'flex items-center justify-center',
+                enabledTextClasses,
+                'py-4 !m-0',
+                'bg-base-neutral-900',
+                'rounded-b-md'
+            )}
+        >
+            <div className="flex justify-center invisible items-center" ref={props.containerRef}>
+                <button disabled={true} ref={props.firstPageRef} className={buttonClasses}>
+                    <ChevronsLeft className={iconClasses} />
+                </button>
+                <button disabled={true} ref={props.previousPageRef} className={buttonClasses}>
+                    <ChevronLeft className={iconClasses} />
+                </button>
+                <span className="px-3 text-sm">
+                    Page <span ref={props.currentPageRef}>0</span> of <span ref={props.totalPagesRef}>0</span>
+                </span>
+                <button disabled={true} ref={props.nextPageRef} className={buttonClasses}>
+                    <ChevronRight className={iconClasses} />
+                </button>
+                <button disabled={true} ref={props.lastPageRef} className={buttonClasses}>
+                    <ChevronsRight className={iconClasses} />
+                </button>
+            </div>
+        </div>
+    );
+};
+
+export const BaseGrid = ({ gridRef, ...props }: BaseTableProps) => {
+    const currentPageRef = useRef<HTMLSpanElement>(null);
+    const totalPagesRef = useRef<HTMLSpanElement>(null);
+    const previousPageRef = useRef<HTMLButtonElement>(null);
+    const nextPageRef = useRef<HTMLButtonElement>(null);
+    const firstPageRef = useRef<HTMLButtonElement>(null);
+    const lastPageRef = useRef<HTMLButtonElement>(null);
+    const containerRef = useRef<HTMLDivElement>(null);
+
+    const onNextPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToNextPage();
+    };
+
+    const onPreviousPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToPreviousPage();
+    };
+
+    const onFirstPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToFirstPage();
+    };
+
+    const onLastPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToLastPage();
+    };
+
+    const onPaginationChanged = () => {
+        const gridApi = gridRef.current?.api;
+        // Make sure the table is loaded before updating the pagination component to avoid flickering
+        if (isTableLoaded && gridApi) {
+            const totalPages = gridApi.paginationGetTotalPages();
+            if (totalPagesRef.current) {
+                totalPagesRef.current.textContent = totalPages.toString();
+            }
+            // Ensure visibility of the pagination panel only after some data is loaded
+            if (containerRef.current) {
+                containerRef.current.style.visibility = totalPages === 0 ? 'hidden' : 'visible';
+            }
+            // Pages are zero based, hence the +1
+            const currentPage = gridApi.paginationGetCurrentPage() + 1;
+            if (currentPageRef.current) {
+                currentPageRef.current.textContent = currentPage.toString();
+            }
+            if (previousPageRef.current) {
+                previousPageRef.current.disabled = currentPage === 1;
+                previousPageRef.current.onclick = onPreviousPage;
+            }
+            if (firstPageRef.current) {
+                firstPageRef.current.disabled = currentPage === 1;
+                firstPageRef.current.onclick = onFirstPage;
+            }
+            if (nextPageRef.current) {
+                nextPageRef.current.disabled = currentPage === totalPages;
+                nextPageRef.current.onclick = onNextPage;
+            }
+            if (lastPageRef.current) {
+                lastPageRef.current.disabled = currentPage === totalPages;
+                lastPageRef.current.onclick = onLastPage;
+            }
+        }
+    };
+
+    // Whether the table component is ready to be displayed
+    const [isTableLoaded, setIsTableLoaded] = useState<boolean>(false);
+
+    const onGridReady = (event: GridReadyEvent) => {
+        setIsTableLoaded(true);
+        if (props.onGridReady) {
+            props.onGridReady(event);
+        }
+    };
+
+    useEffect(() => {
+        onPaginationChanged();
+    }, [isTableLoaded]);
+
+    /* loadingOverlayComponent is shown when the loading hasn't begun yet,
+        whereas noRowsOverlayComponent is shown when the loading has started without data transactions */
+    // TODO: implement skeleton component and match border radius to AG Grid styling precisely
+    return (
+        <>
+            <div className={cn('grid grow w-full', 'relative', 'min-h-[300px]')}>
+                {!isTableLoaded && <div
+                    className="absolute flex items-center justify-center w-full h-full rounded-b-none bg-neutral-800 z-10 rounded-md" />}
+                {/*The substitute div is required to supress hydration warning*/}
+                <AgGridReact {...props} ref={gridRef} onGridReady={onGridReady}
+                             onPaginationChanged={onPaginationChanged} />
+            </div>
+            <SimplePaginationPanel
+                currentPageRef={currentPageRef}
+                totalPagesRef={totalPagesRef}
+                nextPageRef={nextPageRef}
+                previousPageRef={previousPageRef}
+                containerRef={containerRef}
+                firstPageRef={firstPageRef}
+                lastPageRef={lastPageRef}
+            />
+        </>
+    );
+};
diff --git a/packages/ui-kit/lib/components/grids/notification-grid.tsx b/packages/ui-kit/lib/components/grids/notification-grid.tsx
new file mode 100644
index 000000000..31d1f8609
--- /dev/null
+++ b/packages/ui-kit/lib/components/grids/notification-grid.tsx
@@ -0,0 +1,100 @@
+import { notification } from '@maany_shr/e-class-models';
+import { AllCommunityModule, ColDef, ModuleRegistry } from 'ag-grid-community';
+import { AgGridReact } from 'ag-grid-react';
+import { useRef, useState } from 'react';
+import { ChevronDown, ChevronUp } from 'lucide-react';
+import { BaseGrid } from './base-grid';
+import { formatDate } from '../../utils/format-utils';
+import { Button } from '../button';
+
+ModuleRegistry.registerModules([AllCommunityModule]);
+
+export interface NotificationGridProps {
+    notifications: notification.TNotification[];
+    onNotificationClick: (notification: notification.TNotification) => void;
+}
+
+const PAGE_SIZE = 15;
+
+const NotificationMessageRenderer = (props: any) => {
+    const [isExpanded, setIsExpanded] = useState(false);
+
+    const message = props.value;
+    return <div className="flex items-center text-sm my-2.5 space-x-2"
+                onClick={() => setIsExpanded(prevState => !prevState)}>
+        <span className={isExpanded ? 'text-wrap' : 'truncate'}>{message}</span>
+        {isExpanded ? <ChevronUp className="flex-shrink-0 w-4 h-4" /> :
+            <ChevronDown className="flex-shrink-0 w-4 h-4" />}
+    </div>;
+};
+
+const NotificationActionRenderer = (props: any) => {
+    const action = props.value;
+
+    return (
+        <Button
+            variant="text"
+            className="text-sm px-0"
+            text={action.title}
+        />
+    );
+};
+
+const NotificationStatusRenderer = (props: any) => {
+    const isRead = props.value;
+    return isRead ? null : <div className="flex items-center justify-center h-full">
+        <div className="rounded-full bg-base-brand-500 w-2 h-2"></div>
+    </div>;
+};
+
+export const NotificationGrid = ({ notifications, onNotificationClick }: NotificationGridProps) => {
+    const [columnDefs, setColumnDefs] = useState<ColDef[]>([
+        {
+            headerName: '',
+            field: 'isRead',
+            cellRenderer: NotificationStatusRenderer,
+            maxWidth: 40,
+            minWidth: 40
+        },
+        {
+            flex: 1,
+            field: 'message',
+            wrapText: true,
+            autoHeight: true,
+            cellRenderer: NotificationMessageRenderer
+        },
+        {
+            field: 'action',
+            headerName: 'Action',
+            cellRenderer: NotificationActionRenderer,
+            onCellClicked: (event) => {
+                const notification = event.data;
+                onNotificationClick(notification);
+            }
+        },
+        {
+            field: 'timestamp',
+            headerName: 'Date & Time',
+            valueFormatter: (params) => formatDate(new Date(params.value))
+        }
+    ]);
+    const gridRef = useRef<AgGridReact>(null);
+
+    return <div className="flex flex-col h-full @container">
+        <BaseGrid
+            gridRef={gridRef}
+            suppressRowHoverHighlight={true}
+            columnDefs={columnDefs}
+            rowData={notifications}
+            pagination={true}
+            paginationPageSize={PAGE_SIZE}
+            suppressPaginationPanel={true}
+            domLayout="normal"
+            getRowStyle={(params) => {
+                if (!params.data.isRead) {
+                    return { backgroundColor: 'var(--color-base-neutral-800)' };
+                }
+            }}
+        />
+    </div>;
+};
diff --git a/packages/ui-kit/lib/components/grids/user-grid.tsx b/packages/ui-kit/lib/components/grids/user-grid.tsx
new file mode 100644
index 000000000..704513c55
--- /dev/null
+++ b/packages/ui-kit/lib/components/grids/user-grid.tsx
@@ -0,0 +1,138 @@
+import { AgGridReact } from 'ag-grid-react';
+import React, { RefObject, useState } from 'react';
+import { BaseGrid } from './base-grid';
+import { formatDate } from '../../utils/format-utils';
+import { AllCommunityModule, IRowNode, ModuleRegistry, SortChangedEvent } from 'ag-grid-community';
+import { StarRating } from '../star-rating';
+import { Button } from '../button';
+
+ModuleRegistry.registerModules([AllCommunityModule]);
+
+// TODO: define in models
+export interface UserCMS {
+    id: number;
+    name: string;
+    surname: string;
+    email: string;
+    phone: string;
+    rating?: number;
+    platform: string;
+    coachingSessionsCount?: number;
+    coursesBought?: number;
+    coursesCreated?: number;
+    lastAccess: number; // timestamp
+}
+
+export interface UserGridProps {
+    gridRef: RefObject<AgGridReact | null>;
+    onUserDetailsClick: (user: UserCMS) => void;
+    onEmailClick: (email: string) => void;
+    users: UserCMS[];
+    onSortChanged?: (event: SortChangedEvent) => void; // Might be required if we switch to server-side sorting
+    doesExternalFilterPass?: (node: IRowNode<UserCMS>) => boolean;
+    enableSelection?: boolean;
+}
+
+const DetailsCellRenderer = () => {
+    return <Button variant="text" text="Details" className="text-sm px-0" />;
+};
+
+const EmailCellRenderer = (props: any) => {
+    const email = props.value;
+    return <Button variant="text" text={email} className="text-sm px-0" />;
+};
+
+const RatingCellRenderer = (props: any) => {
+    const rating = props.value;
+    if (rating === undefined || rating === null) {
+        return <span>-</span>;
+    }
+
+    const rounded = Number(rating.toFixed(2));
+    const value = rounded % 1 === 0 ? Math.floor(rounded) : rounded;
+
+    return <div className="flex items-center space-x-2">
+        <span>{value}</span>
+        <StarRating rating={rating} totalStars={5} />
+    </div>;
+};
+
+export const UserGrid = (props: UserGridProps) => {
+    const [columnDefs, setColumnDefs] = useState([
+        { field: 'name', headerName: 'Name' },
+        { field: 'surname', headerName: 'Surname' },
+        {
+            cellRenderer: DetailsCellRenderer,
+            onCellClicked: (params: any) => {
+                const user = params.data;
+                props.onUserDetailsClick(user);
+            },
+            cellClass: 'cursor-pointer',
+            width: 100,
+            resizable: false,
+            sortable: false
+        },
+        {
+            field: 'email',
+            headerName: 'Email',
+            cellRenderer: EmailCellRenderer,
+            onCellClicked: (params: any) => {
+                const email = params.value;
+                props.onEmailClick(email);
+            }
+        },
+        { field: 'phone', headerName: 'Phone' },
+        {
+            field: 'rating',
+            headerName: 'Rating',
+            cellRenderer: RatingCellRenderer
+        },
+        { field: 'platform', headerName: 'Platform' },
+        {
+            field: 'coachingSessionsCount', headerName: '# coaching sessions',
+            valueFormatter: (params: any) => {
+                const count = params.value;
+                if (!count || count === 0) return '-';
+                return count;
+            }
+        },
+        {
+            field: 'coursesBought', headerName: 'Courses bought',
+            valueFormatter: (params: any) => {
+                const count = params.value;
+                if (!count || count === 0) return '-';
+                return count;
+            }
+        },
+        {
+            field: 'coursesCreated', headerName: 'Courses created',
+            valueFormatter: (params: any) => {
+                const count = params.value;
+                if (!count || count === 0) return '-';
+                return count;
+            }
+        },
+        {
+            field: 'lastAccess', headerName: 'Last access', valueFormatter: (params: any) => {
+                const date = new Date(params.value);
+                return formatDate(date);
+            }
+        }
+    ]);
+
+    return <div className="flex flex-col h-full">
+        <BaseGrid
+            gridRef={props.gridRef}
+            columnDefs={columnDefs}
+            rowData={props.users}
+            enableCellTextSelection={true}
+            onSortChanged={props.onSortChanged}
+            pagination={true}
+            suppressPaginationPanel={true}
+            paginationAutoPageSize={true}
+            isExternalFilterPresent={() => props.doesExternalFilterPass !== undefined}
+            doesExternalFilterPass={props.doesExternalFilterPass}
+            rowSelection={props.enableSelection ? { mode: 'multiRow' } : undefined}
+        />
+    </div>;
+};
diff --git a/packages/ui-kit/lib/index.ts b/packages/ui-kit/lib/index.ts
index 7ce878ff8..eb82a5d25 100644
--- a/packages/ui-kit/lib/index.ts
+++ b/packages/ui-kit/lib/index.ts
@@ -30,3 +30,6 @@ export { default as BuyCoachingSession, type BuyCoachingSessionProps } from './c
 export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './components/accordion';
 export * as contexts from './contexts';
 export { Navbar } from './components/navbar';
+
+export { NotificationGrid } from './components/grids/notification-grid';
+export { UserGrid } from './components/grids/user-grid';
diff --git a/packages/ui-kit/lib/utils/format-utils.ts b/packages/ui-kit/lib/utils/format-utils.ts
new file mode 100644
index 000000000..3a59388fd
--- /dev/null
+++ b/packages/ui-kit/lib/utils/format-utils.ts
@@ -0,0 +1,11 @@
+// TODO: possibly support 12-hour format
+export function formatDate(date: Date = new Date()): string {
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    const day = String(date.getDate()).padStart(2, '0');
+
+    const hours = String(date.getHours()).padStart(2, '0');
+    const minutes = String(date.getMinutes()).padStart(2, '0');
+
+    return `${year}-${month}-${day} at ${hours}:${minutes}`;
+}
diff --git a/packages/ui-kit/package.json b/packages/ui-kit/package.json
index 7ab769cdf..8d37f6e7e 100644
--- a/packages/ui-kit/package.json
+++ b/packages/ui-kit/package.json
@@ -19,8 +19,11 @@
   "dependencies": {
     "@maany_shr/e-class-models": "workspace:*",
     "@maany_shr/e-class-translations": "workspace:*",
+    "@mux/mux-player-react": "^3.3.0",
     "@testing-library/jest-dom": "^6.6.3",
     "@testing-library/react": "16.1.0",
+    "ag-grid-community": "^33.2.4",
+    "ag-grid-react": "^33.2.4",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "lucide-react": "^0.473.0",
@@ -31,10 +34,9 @@
     "slate-react": "^0.112.1",
     "tailwind-merge": "^2.5.5",
     "vitest": "^2.1.8",
-    "zod": "^3.24.1",
-    "@mux/mux-player-react": "^3.3.0"
+    "zod": "^3.24.1"
   },
   "publishConfig": {
     "access": "public"
   }
-}
\ No newline at end of file
+}
diff --git a/packages/ui-kit/stories/notification-grid.stories.tsx b/packages/ui-kit/stories/notification-grid.stories.tsx
new file mode 100644
index 000000000..081dec6ae
--- /dev/null
+++ b/packages/ui-kit/stories/notification-grid.stories.tsx
@@ -0,0 +1,126 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { NotificationGrid } from '../lib/components/grids/notification-grid';
+
+const meta: Meta<typeof NotificationGrid> = {
+    title: 'Components/NotificationGrid',
+    component: NotificationGrid,
+    tags: ['autodocs'],
+    parameters: {
+        layout: 'fullscreen'
+    },
+    decorators: [
+        (Story) => (
+            <div className="h-screen w-full p-4">
+                <Story />
+            </div>
+        )
+    ]
+};
+
+export default meta;
+type Story = StoryObj<typeof NotificationGrid>;
+
+const longMessage = 'Coach Emma Richards accepted your coaching session request. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
+const longActionText = 'Session details. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
+
+const mockNotifications = [
+    {
+        message: longMessage,
+        action: {
+            title: 'Session details',
+            url: 'https://coaching.example.com/sessions/12345'
+        },
+        timestamp: '2025-04-07T21:30:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coach Michael Johnson suggested a new date for the coaching session',
+        action: {
+            title: longActionText,
+            url: 'https://coaching.example.com/sessions/12346'
+        },
+        timestamp: '2025-04-10T21:17:00.000Z',
+        isRead: true
+    },
+    {
+        message: longMessage,
+        action: {
+            title: longActionText,
+            url: 'https://coaching.example.com/sessions/12347'
+        },
+        timestamp: '2025-04-12T21:17:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coaching session canceled by you',
+        action: {
+            title: 'Session details',
+            url: 'https://coaching.example.com/sessions/12348'
+        },
+        timestamp: '2025-04-15T21:17:00.000Z',
+        isRead: true
+    },
+    {
+        message: 'Coaching session canceled by Coach David Martinez',
+        action: {
+            title: 'Session details',
+            url: 'https://coaching.example.com/sessions/12349'
+        },
+        timestamp: '2025-04-18T21:17:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coach Jennifer Lee replied to your assignment in Advanced Leadership Skills',
+        action: {
+            title: 'View assignment',
+            url: 'https://coaching.example.com/courses/leadership/assignments/89012'
+        },
+        timestamp: '2025-04-19T21:17:00.000Z',
+        isRead: true
+    },
+    {
+        message: 'Coach Robert Chen marked your assignment in Data Science Fundamentals as passed',
+        action: {
+            title: 'View assignment',
+            url: 'https://coaching.example.com/courses/data-science/assignments/45678'
+        },
+        timestamp: '2025-04-20T21:17:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coach Priya Patel has updated the shared workspace for the group Marketing Team',
+        action: {
+            title: 'Group workspace',
+            url: 'https://coaching.example.com/groups/marketing-team/workspace'
+        },
+        timestamp: '2025-04-21T21:17:00.000Z',
+        isRead: true
+    },
+    {
+        message: 'Coach Thomas Wilson has updated the shared workspace for the group Product Development',
+        action: {
+            title: 'Group workspace',
+            url: 'https://coaching.example.com/groups/product-dev/workspace'
+        },
+        timestamp: '2025-04-22T21:17:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coaching session ended',
+        action: {
+            title: 'Rate coach',
+            url: 'https://coaching.example.com/sessions/12350/feedback'
+        },
+        timestamp: '2025-04-23T21:17:00.000Z',
+        isRead: false
+    }
+];
+
+export const Default: Story = {
+    args: {
+        notifications: [...mockNotifications, ...mockNotifications.reverse()],
+        onNotificationClick: (notification) => {
+            alert('Proceeding to ' + notification.action.url);
+        }
+    }
+};
diff --git a/packages/ui-kit/stories/user-grid.stories.tsx b/packages/ui-kit/stories/user-grid.stories.tsx
new file mode 100644
index 000000000..71f0c574a
--- /dev/null
+++ b/packages/ui-kit/stories/user-grid.stories.tsx
@@ -0,0 +1,289 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { UserCMS, UserGrid } from '../lib/components/grids/user-grid';
+import { AgGridReact } from 'ag-grid-react';
+import { useCallback, useRef, useState } from 'react';
+import { Button } from '../lib/components/button';
+import { IRowNode } from 'ag-grid-community';
+
+const meta: Meta<typeof UserGrid> = {
+    title: 'Components/UserGrid',
+    component: UserGrid,
+    tags: ['autodocs'],
+    parameters: {
+        layout: 'fullscreen'
+    },
+    decorators: [
+        (Story, context) => {
+            const gridRef = useRef<AgGridReact>(null);
+            return <div className="h-screen w-full p-4">
+                <Story args={{
+                    ...context.args,
+                    gridRef: gridRef
+                }} />
+            </div>;
+        }
+    ]
+};
+
+const mockUsers = [
+    {
+        id: 1001,
+        name: 'John',
+        surname: 'Smith',
+        email: 'john.smith@example.com',
+        phone: '+1-555-123-4567',
+        rating: 4.7,
+        platform: 'iOS',
+        coachingSessionsCount: 24,
+        coursesBought: 5,
+        coursesCreated: 2,
+        lastAccess: 1714022400000 // April 24, 2024
+    },
+    {
+        id: 1002,
+        name: 'Emma',
+        surname: 'Johnson',
+        email: 'emma.j@example.com',
+        phone: '+1-555-987-6543',
+        rating: 4.9,
+        platform: 'Android',
+        coachingSessionsCount: 38,
+        coursesBought: 12,
+        coursesCreated: 0,
+        lastAccess: 1713936000000 // April 23, 2024
+    },
+    {
+        id: 1003,
+        name: 'Miguel',
+        surname: 'Garcia',
+        email: 'm.garcia@example.com',
+        phone: '+34-611-222-333',
+        rating: 3.8,
+        platform: 'Web',
+        coachingSessionsCount: 7,
+        coursesBought: 3,
+        coursesCreated: 0,
+        lastAccess: 1713418800000 // April 17, 2024
+    },
+    {
+        id: 1004,
+        name: 'Sarah',
+        surname: 'Williams',
+        email: 'swilliams@example.com',
+        phone: '+1-555-444-3333',
+        rating: 5.0,
+        platform: 'iOS',
+        coachingSessionsCount: 52,
+        coursesBought: 0,
+        coursesCreated: 6,
+        lastAccess: 1713849600000 // April 22, 2024
+    },
+    {
+        id: 1005,
+        name: 'Akira',
+        surname: 'Tanaka',
+        email: 'a.tanaka@example.com',
+        phone: '+81-90-1234-5678',
+        rating: undefined,
+        platform: 'Android',
+        coachingSessionsCount: undefined,
+        coursesBought: 7,
+        coursesCreated: 1,
+        lastAccess: 1714022400000 // April 24, 2024
+    },
+    {
+        id: 1006,
+        name: 'Olivia',
+        surname: 'Chen',
+        email: 'oliviac@example.com',
+        phone: '+1-555-777-8888',
+        rating: 4.2,
+        platform: 'Web',
+        coachingSessionsCount: 31,
+        coursesBought: undefined,
+        coursesCreated: 3,
+        lastAccess: 1713504000000 // April 18, 2024
+    },
+    {
+        id: 1007,
+        name: 'Luis',
+        surname: 'Rodriguez',
+        email: 'luis.r@example.com',
+        phone: '+52-555-987-6543',
+        rating: 3.5,
+        platform: 'iOS',
+        coachingSessionsCount: 9,
+        coursesBought: 2,
+        coursesCreated: 0,
+        lastAccess: 1712812800000 // April 10, 2024
+    },
+    {
+        id: 1008,
+        name: 'Anna',
+        surname: 'Kowalski',
+        email: 'annak@example.com',
+        phone: '+48-512-345-678',
+        rating: 4.8,
+        platform: 'Android',
+        coachingSessionsCount: 45,
+        coursesBought: 9,
+        coursesCreated: 5,
+        lastAccess: 1713763200000 // April 21, 2024
+    },
+    {
+        id: 1009,
+        name: 'David',
+        surname: 'Nguyen',
+        email: 'd.nguyen@example.com',
+        phone: '+1-555-222-1111',
+        rating: undefined,
+        platform: 'Web',
+        coachingSessionsCount: 18,
+        coursesBought: 6,
+        coursesCreated: 0,
+        lastAccess: 1713676800000 // April 20, 2024
+    },
+    {
+        id: 1010,
+        name: 'Maria',
+        surname: 'Silva',
+        email: 'maria.s@example.com',
+        phone: '+55-11-98765-4321',
+        rating: 4.1,
+        platform: 'iOS',
+        coachingSessionsCount: 27,
+        coursesBought: 3,
+        coursesCreated: 1,
+        lastAccess: 1713936000000 // April 23, 2024
+    },
+    {
+        id: 1011,
+        name: 'Alexandraxandraxandraxandraxandraxandraxandraxandraxandraxandraxandrahxandrah',
+        surname: 'Constantinopolisworthingtonschlegelsteinhausenbergerdorffweilerwilsonsmithfield',
+        email: 'alexandraconstantinopolisworthingtonschlegelsteinhausenbergerdorffweilerwilsonsmithfield_verylongemail_testing_overflow_conditions_with_extra_characters_to_ensure_proper_handling@verylongdomainnametotestoverflowconditions.co.educational.systems',
+        phone: '+123-456-7890-1234-5678-9012-3456-7890-1234-5678-9012-3456-7890-1234-5678-9012',
+        rating: 4.9999999999999,
+        platform: 'iOS/Android/Web/Desktop/Mobile/Tablet/SmartTV/Wearable/IoT/EmbeddedSystems',
+        coachingSessionsCount: 9999999,
+        coursesBought: 88888888,
+        coursesCreated: 7777777,
+        lastAccess: 9999999999999 // Far future date to test timestamp overflow
+    }
+];
+
+export default meta;
+type Story = StoryObj<typeof UserGrid>;
+
+export const Default: Story = {
+    args: {
+        users: Array.from({ length: 5 }, () => [...mockUsers]).flat(),
+        onUserDetailsClick: (user) => {
+            alert(`User details clicked: ${user.name} ${user.surname}`);
+        },
+        onEmailClick: (email) => {
+            alert(`Email clicked: ${email}`);
+        }
+    }
+};
+
+export const Selectable: Story = {
+    args: {
+        users: Array.from({ length: 5 }, () => [...mockUsers]).flat(),
+        enableSelection: true,
+        onUserDetailsClick: (user) => {
+            alert(`User details clicked: ${user.name} ${user.surname}`);
+        },
+        onEmailClick: (email) => {
+            alert(`Email clicked: ${email}`);
+        }
+    }
+};
+
+export const Empty: Story = {
+    args: {
+        users: [],
+        onUserDetailsClick: (user) => {
+            alert(`User details clicked: ${user.name} ${user.surname}`);
+        },
+        onEmailClick: (email) => {
+            alert(`Email clicked: ${email}`);
+        }
+    }
+};
+
+export const Loading: Story = {
+    args: {
+        users: undefined,
+        onUserDetailsClick: (user) => {
+            alert(`User details clicked: ${user.name} ${user.surname}`);
+        },
+        onEmailClick: (email) => {
+            alert(`Email clicked: ${email}`);
+        }
+    }
+};
+
+export const Filter: Story = {
+    args: {
+        users: Array.from({ length: 5 }, () => [...mockUsers]).flat(),
+        onUserDetailsClick: (user) => {
+            alert(`User details clicked: ${user.name} ${user.surname}`);
+        },
+        onEmailClick: (email) => {
+            alert(`Email clicked: ${email}`);
+        }
+    },
+    decorators: [
+        (Story, context) => {
+            const [filteringByName, setFilteringByName] = useState<boolean>(false);
+            const [filteringByPhone, setFilteringByPhone] = useState<boolean>(false);
+            const [filteringByRating, setFilteringByRating] = useState<boolean>(false);
+            const [filteringByAccess, setFilteringByAccess] = useState<boolean>(false);
+
+            const doesFilterPass = useCallback((node: IRowNode<UserCMS>) => {
+                if (filteringByName && !node.data.name.toLowerCase().includes('an')) {
+                    return false;
+                }
+                if (filteringByPhone && !node.data.phone.startsWith('+1')) {
+                    return false;
+                }
+                if (filteringByRating && ((node.data.rating !== undefined && node.data.rating <= 4.5) || !node.data.rating)) {
+                    return false;
+                }
+                if (filteringByAccess && node.data.lastAccess > new Date('2024-04-20').getTime()) {
+                    return false;
+                }
+                return true;
+            }, [filteringByName, filteringByPhone, filteringByRating, filteringByAccess]);
+
+            const filterByName = () => {
+                setFilteringByName(prevState => !prevState);
+            };
+
+            const filterByPhone = () => {
+                setFilteringByPhone(prevState => !prevState);
+            };
+
+            const filterByRating = () => {
+                setFilteringByRating(prevState => !prevState);
+            };
+
+            const filterByAccess = () => {
+                setFilteringByAccess(prevState => !prevState);
+            };
+
+            return <div className="flex grow h-full w-full flex-col">
+                <div className="flex space-x-2">
+                    <Button text="Filter by name contains an" onClick={filterByName} />
+                    <Button text="Filter by phone starts with +1" onClick={filterByPhone} />
+                    <Button text="Filter by rating greater than 4.5" onClick={filterByRating} />
+                    <Button text="Filter by access before April 20" onClick={filterByAccess} />
+                </div>
+                <Story args={{
+                    ...context.args,
+                    doesExternalFilterPass: doesFilterPass
+                }} />
+            </div>;
+        }
+    ]
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 63a62269c..69b6e179b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -319,6 +319,12 @@ importers:
       '@testing-library/react':
         specifier: 16.1.0
         version: 16.1.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      ag-grid-community:
+        specifier: ^33.2.4
+        version: 33.2.4
+      ag-grid-react:
+        specifier: ^33.2.4
+        version: 33.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
       class-variance-authority:
         specifier: ^0.7.1
         version: 0.7.1
@@ -3425,6 +3431,18 @@ packages:
     resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
     engines: {node: '>=12.0'}
 
+  ag-charts-types@11.2.4:
+    resolution: {integrity: sha512-a1aQjtQ9ZH+J8BF3YcWAIHUt9ZLiL9IVZ8R5h7z2CkTNu2TPq5UlsCcF0/YZKc1M8CM0CdSNLSGQuWUwDNxAcA==}
+
+  ag-grid-community@33.2.4:
+    resolution: {integrity: sha512-7XT1+wxmMlMVXB27BJOzlft9ATB0C5HOJcCGMc8dCO2W17JXbNknVEEDGTBKoSvA4Cei8sARrRDxmPgY8mWopQ==}
+
+  ag-grid-react@33.2.4:
+    resolution: {integrity: sha512-rP6zwrGxRimAf6blJQZDahE0t2LM2zqqOEEeBacD6M5coWP+zOESja9vklpyzU+UfIlyqToZ2O+rboBQt3Wqxg==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+      react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
   agent-base@6.0.2:
     resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
     engines: {node: '>= 6.0.0'}
@@ -12802,6 +12820,19 @@ snapshots:
 
   adm-zip@0.5.16: {}
 
+  ag-charts-types@11.2.4: {}
+
+  ag-grid-community@33.2.4:
+    dependencies:
+      ag-charts-types: 11.2.4
+
+  ag-grid-react@33.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+    dependencies:
+      ag-grid-community: 33.2.4
+      prop-types: 15.8.1
+      react: 19.0.0
+      react-dom: 19.0.0(react@19.0.0)
+
   agent-base@6.0.2:
     dependencies:
       debug: 4.4.0


Reviews:
user: login: alebg
body: great job @MytsV, thanks a lot!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:46:46Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR implements grids for notifications and users, including a custom pagination panel based on BaseGrid and accompanying Storybook examples. It adds custom cell renderers, new date formatting utilities, and updates the module exports accordingly.
- Implements NotificationGrid and UserGrid components with custom renderers and interaction callbacks.
- Introduces BaseGrid with a custom, responsive pagination panel.
- Adds Storybook stories and mock data to demonstrate grid behaviors.

### Reviewed Changes

Copilot reviewed 7 out of 10 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File                                                                 | Description                                                            |
|----------------------------------------------------------------------|------------------------------------------------------------------------|
| packages/ui-kit/stories/user-grid.stories.tsx                        | Adds Storybook examples for the UserGrid with filtering and selection. |
| packages/ui-kit/stories/notification-grid.stories.tsx                 | Adds Storybook examples for the NotificationGrid with various notifications. |
| packages/ui-kit/lib/utils/format-utils.ts                              | Introduces a basic date formatting utility.                            |
| packages/ui-kit/lib/index.ts                                           | Updates exports for the new NotificationGrid, NotificationCard, and UserGrid. |
| packages/ui-kit/lib/components/grids/user-grid.tsx                     | Implements the UserGrid component with custom cell renderers.          |
| packages/ui-kit/lib/components/grids/notification-grid.tsx             | Implements the NotificationGrid component with a card layout.          |
| packages/ui-kit/lib/components/grids/base-grid.tsx                     | Implements BaseGrid with a custom pagination panel.                    |
</details>


<details>
<summary>Files not reviewed (3)</summary>

* **packages/ui-kit/lib/assets/css/tailwind.css**: Language not supported
* **packages/ui-kit/package.json**: Language not supported
* **pnpm-lock.yaml**: Language not supported
</details>



state: COMMENTED
author_association: NONE
submitted_at: 2025-04-28T18:48:14Z
user: login: alebg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T21:01:24Z
user: login: alebg
body: Sorry @MytsV I noticed a couple of things. Please address my inline comments, including Copilot's. And please remove all unused imports
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T11:12:14Z
user: login: MytsV
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-30T12:00:39Z
user: login: MytsV
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-30T12:04:19Z
user: login: alebg
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T12:56:44Z

Review comments:
diff_hunk: @@ -0,0 +1,126 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { NotificationGrid } from '../lib/components/grids/notification-grid';
+
+const meta: Meta<typeof NotificationGrid> = {
+    title: 'Components/NotificationGrid',
+    component: NotificationGrid,
+    tags: ['autodocs'],
+    parameters: {
+        layout: 'fullscreen'
+    },
+    decorators: [
+        (Story) => (
+            <div className="h-screen w-full p-4">
+                <Story />
+            </div>
+        )
+    ]
+};
+
+export default meta;
+type Story = StoryObj<typeof NotificationGrid>;
+
+const longMessage = 'Coach Emma Richards accepted your coaching session request. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
+const longActionText = 'Session details. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
+
+const mockNotifications = [
+    {
+        message: longMessage,
+        action: {
+            title: 'Session details',
+            url: 'https://coaching.example.com/sessions/12345'
+        },
+        timestamp: '2025-04-07T21:30:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coach Michael Johnson suggested a new date for the coaching session',
+        action: {
+            title: longActionText,
+            url: 'https://coaching.example.com/sessions/12346'
+        },
+        timestamp: '2025-04-10T21:17:00.000Z',
+        isRead: true
+    },
+    {
+        message: longMessage,
+        action: {
+            title: longActionText,
+            url: 'https://coaching.example.com/sessions/12347'
+        },
+        timestamp: '2025-04-12T21:17:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coaching session canceled by you',
+        action: {
+            title: 'Session details',
+            url: 'https://coaching.example.com/sessions/12348'
+        },
+        timestamp: '2025-04-15T21:17:00.000Z',
+        isRead: true
+    },
+    {
+        message: 'Coaching session canceled by Coach David Martinez',
+        action: {
+            title: 'Session details',
+            url: 'https://coaching.example.com/sessions/12349'
+        },
+        timestamp: '2025-04-18T21:17:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coach Jennifer Lee replied to your assignment in Advanced Leadership Skills',
+        action: {
+            title: 'View assignment',
+            url: 'https://coaching.example.com/courses/leadership/assignments/89012'
+        },
+        timestamp: '2025-04-19T21:17:00.000Z',
+        isRead: true
+    },
+    {
+        message: 'Coach Robert Chen marked your assignment in Data Science Fundamentals as passed',
+        action: {
+            title: 'View assignment',
+            url: 'https://coaching.example.com/courses/data-science/assignments/45678'
+        },
+        timestamp: '2025-04-20T21:17:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coach Priya Patel has updated the shared workspace for the group Marketing Team',
+        action: {
+            title: 'Group workspace',
+            url: 'https://coaching.example.com/groups/marketing-team/workspace'
+        },
+        timestamp: '2025-04-21T21:17:00.000Z',
+        isRead: true
+    },
+    {
+        message: 'Coach Thomas Wilson has updated the shared workspace for the group Product Development',
+        action: {
+            title: 'Group workspace',
+            url: 'https://coaching.example.com/groups/product-dev/workspace'
+        },
+        timestamp: '2025-04-22T21:17:00.000Z',
+        isRead: false
+    },
+    {
+        message: 'Coaching session ended',
+        action: {
+            title: 'Rate coach',
+            url: 'https://coaching.example.com/sessions/12350/feedback'
+        },
+        timestamp: '2025-04-23T21:17:00.000Z',
+        isRead: false
+    }
+];
+
+export const Default: Story = {
+    args: {
+        notifications: [...mockNotifications, ...mockNotifications.reverse()],
path: packages/ui-kit/stories/notification-grid.stories.tsx
user: login: Copilot
body: Using reverse() directly mutates 'mockNotifications', which may lead to unexpected behavior in subsequent renders. Instead, consider using a non-mutating approach like [...mockNotifications].reverse() or mockNotifications.slice().reverse() to preserve the original order.
```suggestion
        notifications: [...mockNotifications, ...[...mockNotifications].reverse()],
```
created_at: 2025-04-28T18:48:13Z
author_association: NONE
diff_hunk: @@ -0,0 +1,150 @@
+import { RefObject, useEffect, useRef, useState } from 'react';
+import { cn } from '../../utils/style-utils';
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
+import { AgGridReact, AgGridReactProps } from 'ag-grid-react';
+import { GridReadyEvent } from 'ag-grid-community';
+
+export interface BaseTableProps extends AgGridReactProps {
+    gridRef: RefObject<AgGridReact | null>;
+}
+
+/**
+ * A component for flexible and responsive pagination of the table
+ */
+export const SimplePaginationPanel = (props: {
+    currentPageRef: RefObject<HTMLSpanElement | null>;
+    totalPagesRef: RefObject<HTMLSpanElement | null>;
+    previousPageRef: RefObject<HTMLButtonElement | null>;
+    nextPageRef: RefObject<HTMLButtonElement | null>;
+    lastPageRef: RefObject<HTMLButtonElement | null>;
+    firstPageRef: RefObject<HTMLButtonElement | null>;
+    containerRef: RefObject<HTMLDivElement | null>;
+}) => {
+    const enabledTextClasses = 'text-neutral-50';
+    const disabledTextClasses = 'disabled:text-neutral-500';
+    const buttonClasses = cn('text-l cursor-pointer', 'px-1', enabledTextClasses, disabledTextClasses);
+    const iconClasses = 'h-5 w-5';
+
+    return (
+        <div
+            className={cn(
+                'flex items-center justify-center',
+                enabledTextClasses,
+                'py-4 !m-0',
+                'bg-base-neutral-900',
+                'rounded-b-md'
+            )}
+        >
+            <div className="flex justify-center invisible items-center" ref={props.containerRef}>
+                <button disabled={true} ref={props.firstPageRef} className={buttonClasses}>
+                    <ChevronsLeft className={iconClasses} />
+                </button>
+                <button disabled={true} ref={props.previousPageRef} className={buttonClasses}>
+                    <ChevronLeft className={iconClasses} />
+                </button>
+                <span className="px-3 text-sm">
+                    Page <span ref={props.currentPageRef}>0</span> of <span ref={props.totalPagesRef}>0</span>
+                </span>
+                <button disabled={true} ref={props.nextPageRef} className={buttonClasses}>
+                    <ChevronRight className={iconClasses} />
+                </button>
+                <button disabled={true} ref={props.lastPageRef} className={buttonClasses}>
+                    <ChevronsRight className={iconClasses} />
+                </button>
+            </div>
+        </div>
+    );
+};
+
+export const BaseGrid = ({ gridRef, ...props }: BaseTableProps) => {
+    const currentPageRef = useRef<HTMLSpanElement>(null);
+    const totalPagesRef = useRef<HTMLSpanElement>(null);
+    const previousPageRef = useRef<HTMLButtonElement>(null);
+    const nextPageRef = useRef<HTMLButtonElement>(null);
+    const firstPageRef = useRef<HTMLButtonElement>(null);
+    const lastPageRef = useRef<HTMLButtonElement>(null);
+    const containerRef = useRef<HTMLDivElement>(null);
+
+    const onNextPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToNextPage();
+    };
+
+    const onPreviousPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToPreviousPage();
+    };
+
+    const onFirstPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToFirstPage();
+    };
+
+    const onLastPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToLastPage();
+    };
+
+    const onPaginationChanged = () => {
+        const gridApi = gridRef.current?.api;
+        // Make sure the table is loaded before updating the pagination component to avoid flickering
+        if (isTableLoaded && gridApi) {
+            const totalPages = gridApi.paginationGetTotalPages();
+            totalPagesRef.current!.textContent = totalPages.toString();
+            // Ensure visibility of the pagination panel only after some data is loaded
+            containerRef.current!.style.visibility = totalPages === 0 ? 'hidden' : 'visible';
+
+            // Pages are zero based, hence the +1
+            const currentPage = gridApi.paginationGetCurrentPage() + 1;
+            currentPageRef.current!.textContent = currentPage.toString();
+
+            previousPageRef.current!.disabled = currentPage === 1;
+            firstPageRef.current!.disabled = currentPage === 1;
+            previousPageRef.current!.onclick = onPreviousPage;
+            firstPageRef.current!.onclick = onFirstPage;
+
+            nextPageRef.current!.disabled = currentPage === totalPages;
+            lastPageRef.current!.disabled = currentPage === totalPages;
+            nextPageRef.current!.onclick = onNextPage;
+            lastPageRef.current!.onclick = onLastPage;
+        }
path: packages/ui-kit/lib/components/grids/base-grid.tsx
user: login: Copilot
body: The non-null assertions (using '!') on DOM refs can lead to runtime errors if the elements are not mounted when accessed. Consider adding conditional checks to ensure these refs are defined before modifying their properties.
```suggestion
            if (totalPagesRef.current) {
                totalPagesRef.current.textContent = totalPages.toString();
            }
            // Ensure visibility of the pagination panel only after some data is loaded
            if (containerRef.current) {
                containerRef.current.style.visibility = totalPages === 0 ? 'hidden' : 'visible';
            }

            // Pages are zero based, hence the +1
            const currentPage = gridApi.paginationGetCurrentPage() + 1;
            if (currentPageRef.current) {
                currentPageRef.current.textContent = currentPage.toString();
            }

            if (previousPageRef.current) {
                previousPageRef.current.disabled = currentPage === 1;
                previousPageRef.current.onclick = onPreviousPage;
            }
            if (firstPageRef.current) {
                firstPageRef.current.disabled = currentPage === 1;
                firstPageRef.current.onclick = onFirstPage;
            }

            if (nextPageRef.current) {
                nextPageRef.current.disabled = currentPage === totalPages;
                nextPageRef.current.onclick = onNextPage;
            }
            if (lastPageRef.current) {
                lastPageRef.current.disabled = currentPage === totalPages;
                lastPageRef.current.onclick = onLastPage;
            }
```
created_at: 2025-04-28T18:48:13Z
author_association: NONE
diff_hunk: @@ -0,0 +1,150 @@
+import { RefObject, useEffect, useRef, useState } from 'react';
+import { cn } from '../../utils/style-utils';
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
+import { AgGridReact, AgGridReactProps } from 'ag-grid-react';
+import { GridReadyEvent } from 'ag-grid-community';
+
+export interface BaseTableProps extends AgGridReactProps {
+    gridRef: RefObject<AgGridReact | null>;
+}
+
+/**
+ * A component for flexible and responsive pagination of the table
+ */
+export const SimplePaginationPanel = (props: {
+    currentPageRef: RefObject<HTMLSpanElement | null>;
+    totalPagesRef: RefObject<HTMLSpanElement | null>;
+    previousPageRef: RefObject<HTMLButtonElement | null>;
+    nextPageRef: RefObject<HTMLButtonElement | null>;
+    lastPageRef: RefObject<HTMLButtonElement | null>;
+    firstPageRef: RefObject<HTMLButtonElement | null>;
+    containerRef: RefObject<HTMLDivElement | null>;
+}) => {
+    const enabledTextClasses = 'text-neutral-50';
+    const disabledTextClasses = 'disabled:text-neutral-500';
+    const buttonClasses = cn('text-l cursor-pointer', 'px-1', enabledTextClasses, disabledTextClasses);
+    const iconClasses = 'h-5 w-5';
+
+    return (
+        <div
+            className={cn(
+                'flex items-center justify-center',
+                enabledTextClasses,
+                'py-4 !m-0',
+                'bg-base-neutral-900',
+                'rounded-b-md'
+            )}
+        >
+            <div className="flex justify-center invisible items-center" ref={props.containerRef}>
+                <button disabled={true} ref={props.firstPageRef} className={buttonClasses}>
+                    <ChevronsLeft className={iconClasses} />
+                </button>
+                <button disabled={true} ref={props.previousPageRef} className={buttonClasses}>
+                    <ChevronLeft className={iconClasses} />
+                </button>
+                <span className="px-3 text-sm">
+                    Page <span ref={props.currentPageRef}>0</span> of <span ref={props.totalPagesRef}>0</span>
+                </span>
+                <button disabled={true} ref={props.nextPageRef} className={buttonClasses}>
+                    <ChevronRight className={iconClasses} />
+                </button>
+                <button disabled={true} ref={props.lastPageRef} className={buttonClasses}>
+                    <ChevronsRight className={iconClasses} />
+                </button>
+            </div>
+        </div>
+    );
+};
+
+export const BaseGrid = ({ gridRef, ...props }: BaseTableProps) => {
+    const currentPageRef = useRef<HTMLSpanElement>(null);
+    const totalPagesRef = useRef<HTMLSpanElement>(null);
+    const previousPageRef = useRef<HTMLButtonElement>(null);
+    const nextPageRef = useRef<HTMLButtonElement>(null);
+    const firstPageRef = useRef<HTMLButtonElement>(null);
+    const lastPageRef = useRef<HTMLButtonElement>(null);
+    const containerRef = useRef<HTMLDivElement>(null);
+
+    const onNextPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToNextPage();
+    };
+
+    const onPreviousPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToPreviousPage();
+    };
+
+    const onFirstPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToFirstPage();
+    };
+
+    const onLastPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToLastPage();
+    };
+
+    const onPaginationChanged = () => {
+        const gridApi = gridRef.current?.api;
+        // Make sure the table is loaded before updating the pagination component to avoid flickering
+        if (isTableLoaded && gridApi) {
+            const totalPages = gridApi.paginationGetTotalPages();
+            totalPagesRef.current!.textContent = totalPages.toString();
+            // Ensure visibility of the pagination panel only after some data is loaded
+            containerRef.current!.style.visibility = totalPages === 0 ? 'hidden' : 'visible';
+
+            // Pages are zero based, hence the +1
+            const currentPage = gridApi.paginationGetCurrentPage() + 1;
+            currentPageRef.current!.textContent = currentPage.toString();
+
+            previousPageRef.current!.disabled = currentPage === 1;
+            firstPageRef.current!.disabled = currentPage === 1;
+            previousPageRef.current!.onclick = onPreviousPage;
+            firstPageRef.current!.onclick = onFirstPage;
+
+            nextPageRef.current!.disabled = currentPage === totalPages;
+            lastPageRef.current!.disabled = currentPage === totalPages;
+            nextPageRef.current!.onclick = onNextPage;
+            lastPageRef.current!.onclick = onLastPage;
+        }
path: packages/ui-kit/lib/components/grids/base-grid.tsx
user: login: alebg
body: @MytsV could you please do it in this second way, so we don't risk doing `null.current` at some point?
created_at: 2025-04-28T21:01:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,132 @@
+import { notification } from '@maany_shr/e-class-models';
+import { AllCommunityModule, ColDef, ModuleRegistry } from 'ag-grid-community';
+import { AgGridReact } from 'ag-grid-react';
+import { useRef, useState } from 'react';
+import { Check, ChevronDown, ChevronUp, Dot } from 'lucide-react';
+import { cn } from '../../utils/style-utils';
+import { BaseGrid } from './base-grid';
+import { formatDate } from '../../utils/format-utils';
+import { Button } from '../button';
+
+ModuleRegistry.registerModules([AllCommunityModule]);
+
+export interface NotificationGridProps {
+    notifications: notification.TNotification[];
+    onNotificationClick: (notification: notification.TNotification) => void;
+}
+
+// TODO: ensure styling matching Figma
+export const NotificationCard = ({ notification, onClick }: {
+    notification: notification.TNotification,
+    onClick: (notification: notification.TNotification) => void;
+}) => {
+    const isPending = !notification.isRead;
+    return (
+        <div className={cn(
+            '@3xl:flex items-center p-4 justify-between rounded-md my-2 space-x-2',
+            isPending && 'bg-base-neutral-800'
+        )}>
+            <p className="text-base-neutral-50 text-wrap text-sm">{notification.message}</p>
+            <div className="flex space-x-2 items-center">
+                <Button
+                    variant="text"
+                    className="text-sm px-0 truncate max-w-sm"
+                    onClick={() => onClick(notification)}
+                    text={notification.action.title}
+                />
+                <p className="text-xs text-base-neutral-300">{formatDate(new Date(notification.timestamp))}</p>
+                {isPending && <Check className="text-base-brand-500 h-5 w-5 flex-shrink-0" />}
+            </div>
+        </div>
+    );
+};
+
+const NotificationCellRenderer = (props: any) => {
path: packages/ui-kit/lib/components/grids/notification-grid.tsx
user: login: alebg
body: If this is not being used anymore, it should be removed
created_at: 2025-04-30T11:10:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,132 @@
+import { notification } from '@maany_shr/e-class-models';
+import { AllCommunityModule, ColDef, ModuleRegistry } from 'ag-grid-community';
+import { AgGridReact } from 'ag-grid-react';
+import { useRef, useState } from 'react';
+import { Check, ChevronDown, ChevronUp, Dot } from 'lucide-react';
+import { cn } from '../../utils/style-utils';
+import { BaseGrid } from './base-grid';
+import { formatDate } from '../../utils/format-utils';
+import { Button } from '../button';
+
+ModuleRegistry.registerModules([AllCommunityModule]);
+
+export interface NotificationGridProps {
+    notifications: notification.TNotification[];
+    onNotificationClick: (notification: notification.TNotification) => void;
+}
+
+// TODO: ensure styling matching Figma
+export const NotificationCard = ({ notification, onClick }: {
path: packages/ui-kit/lib/components/grids/notification-grid.tsx
user: login: alebg
body: Same here
created_at: 2025-04-30T11:10:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,150 @@
+import { RefObject, useEffect, useRef, useState } from 'react';
+import { cn } from '../../utils/style-utils';
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
+import { AgGridReact, AgGridReactProps } from 'ag-grid-react';
+import { GridReadyEvent } from 'ag-grid-community';
+
+export interface BaseTableProps extends AgGridReactProps {
+    gridRef: RefObject<AgGridReact | null>;
+}
+
+/**
+ * A component for flexible and responsive pagination of the table
+ */
+export const SimplePaginationPanel = (props: {
+    currentPageRef: RefObject<HTMLSpanElement | null>;
+    totalPagesRef: RefObject<HTMLSpanElement | null>;
+    previousPageRef: RefObject<HTMLButtonElement | null>;
+    nextPageRef: RefObject<HTMLButtonElement | null>;
+    lastPageRef: RefObject<HTMLButtonElement | null>;
+    firstPageRef: RefObject<HTMLButtonElement | null>;
+    containerRef: RefObject<HTMLDivElement | null>;
+}) => {
+    const enabledTextClasses = 'text-neutral-50';
+    const disabledTextClasses = 'disabled:text-neutral-500';
+    const buttonClasses = cn('text-l cursor-pointer', 'px-1', enabledTextClasses, disabledTextClasses);
+    const iconClasses = 'h-5 w-5';
+
+    return (
+        <div
+            className={cn(
+                'flex items-center justify-center',
+                enabledTextClasses,
+                'py-4 !m-0',
+                'bg-base-neutral-900',
+                'rounded-b-md'
+            )}
+        >
+            <div className="flex justify-center invisible items-center" ref={props.containerRef}>
+                <button disabled={true} ref={props.firstPageRef} className={buttonClasses}>
+                    <ChevronsLeft className={iconClasses} />
+                </button>
+                <button disabled={true} ref={props.previousPageRef} className={buttonClasses}>
+                    <ChevronLeft className={iconClasses} />
+                </button>
+                <span className="px-3 text-sm">
+                    Page <span ref={props.currentPageRef}>0</span> of <span ref={props.totalPagesRef}>0</span>
+                </span>
+                <button disabled={true} ref={props.nextPageRef} className={buttonClasses}>
+                    <ChevronRight className={iconClasses} />
+                </button>
+                <button disabled={true} ref={props.lastPageRef} className={buttonClasses}>
+                    <ChevronsRight className={iconClasses} />
+                </button>
+            </div>
+        </div>
+    );
+};
+
+export const BaseGrid = ({ gridRef, ...props }: BaseTableProps) => {
+    const currentPageRef = useRef<HTMLSpanElement>(null);
+    const totalPagesRef = useRef<HTMLSpanElement>(null);
+    const previousPageRef = useRef<HTMLButtonElement>(null);
+    const nextPageRef = useRef<HTMLButtonElement>(null);
+    const firstPageRef = useRef<HTMLButtonElement>(null);
+    const lastPageRef = useRef<HTMLButtonElement>(null);
+    const containerRef = useRef<HTMLDivElement>(null);
+
+    const onNextPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToNextPage();
+    };
+
+    const onPreviousPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToPreviousPage();
+    };
+
+    const onFirstPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToFirstPage();
+    };
+
+    const onLastPage = () => {
+        const gridApi = gridRef.current?.api;
+        gridApi?.paginationGoToLastPage();
+    };
+
+    const onPaginationChanged = () => {
+        const gridApi = gridRef.current?.api;
+        // Make sure the table is loaded before updating the pagination component to avoid flickering
+        if (isTableLoaded && gridApi) {
+            const totalPages = gridApi.paginationGetTotalPages();
+            totalPagesRef.current!.textContent = totalPages.toString();
+            // Ensure visibility of the pagination panel only after some data is loaded
+            containerRef.current!.style.visibility = totalPages === 0 ? 'hidden' : 'visible';
+
+            // Pages are zero based, hence the +1
+            const currentPage = gridApi.paginationGetCurrentPage() + 1;
+            currentPageRef.current!.textContent = currentPage.toString();
+
+            previousPageRef.current!.disabled = currentPage === 1;
+            firstPageRef.current!.disabled = currentPage === 1;
+            previousPageRef.current!.onclick = onPreviousPage;
+            firstPageRef.current!.onclick = onFirstPage;
+
+            nextPageRef.current!.disabled = currentPage === totalPages;
+            lastPageRef.current!.disabled = currentPage === totalPages;
+            nextPageRef.current!.onclick = onNextPage;
+            lastPageRef.current!.onclick = onLastPage;
+        }
path: packages/ui-kit/lib/components/grids/base-grid.tsx
user: login: MytsV
body: I first implemented this in WebUI with the notion that it should give an error if we don't initialize the pagination panel. But it makes sense not to throw explicitly if this happens
created_at: 2025-04-30T12:00:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,132 @@
+import { notification } from '@maany_shr/e-class-models';
+import { AllCommunityModule, ColDef, ModuleRegistry } from 'ag-grid-community';
+import { AgGridReact } from 'ag-grid-react';
+import { useRef, useState } from 'react';
+import { Check, ChevronDown, ChevronUp, Dot } from 'lucide-react';
+import { cn } from '../../utils/style-utils';
+import { BaseGrid } from './base-grid';
+import { formatDate } from '../../utils/format-utils';
+import { Button } from '../button';
+
+ModuleRegistry.registerModules([AllCommunityModule]);
+
+export interface NotificationGridProps {
+    notifications: notification.TNotification[];
+    onNotificationClick: (notification: notification.TNotification) => void;
+}
+
+// TODO: ensure styling matching Figma
+export const NotificationCard = ({ notification, onClick }: {
path: packages/ui-kit/lib/components/grids/notification-grid.tsx
user: login: MytsV
body: The reason why I left this component in handling of responsiveness there that I believe should be ported to the already merged NotificationCard. I have removed this in the latest commit, but I will then leave a link to this in my review task in Notion 
created_at: 2025-04-30T12:04:19Z
author_association: COLLABORATOR

PR comments:
user: login: notion-workspace[bot]
created_at: 2025-04-24T11:42:24Z
body: <a href="https://www.notion.so/ui-kit-ready-to-go-UserGrid-and-NotificationGrid-with-styling-guidelines-1d35a7432d01803db309fd29ee7b9bc4?pvs=8&n=github_linkback">ui-kit: ready-to-go UserGrid and NotificationGrid, with styling guidelines</a>
author_association: NONE
user: login: MytsV
created_at: 2025-04-24T11:47:02Z
body: There's a typing error failing the build, I fill fix it in as soon as I can
author_association: COLLABORATOR
user: login: alebg
created_at: 2025-04-24T13:45:45Z
body: @MytsV thanks a lot! I'll take a look later, but in the meantime could you please check why the CI is failing?
author_association: CONTRIBUTOR
user: login: MytsV
created_at: 2025-04-28T14:25:01Z
body: ![image](https://github.com/user-attachments/assets/9b599876-a6f6-49eb-8b74-1f990f7136cb)
Here's the updated version of NotificationGrid. Notes on some decisions:
- expandable is always turned on, as otherwise a resize event listener would have to be implemented, which with this amount of elements would definitely be suboptimal
- auto pagination is turned off and replaced with a static value to mitigate another flickering issue
author_association: COLLABORATOR
user: login: MytsV
created_at: 2025-04-30T11:59:25Z
body: Hey @alebg, thank you for the review! I have included the feedback in the latest commit. Also, took some time to fight with AG Grid and fix huge line spacing:
![image](https://github.com/user-attachments/assets/e1740def-e0a3-4884-b745-95d7266a697c)

author_association: COLLABORATOR
user: login: alebg
created_at: 2025-04-30T21:10:34Z
body: Fixed #141
author_association: CONTRIBUTOR