From 7fd0031da5ffe9ae3bedca76f7e099bdfc8fe44e Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Fri, 22 Aug 2025 06:33:39 +0200 Subject: [PATCH 01/40] Frontend: audit log --- changelog.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index b5e42109f..0ab145260 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,9 @@ # Changelog +## 3.6 - not yet released + +- Audit log + ## 3.5.3 - 2025-08-18 - Performance - async instead of sync: all REST API/DB operations are changed to async diff --git a/pyproject.toml b/pyproject.toml index 9e055eda4..d21ce3af0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "papermerge" -version = "3.5.3" +version = "3.6.0" description = "Open source document management system for digital archives" authors = [ { name = "Eugen Ciur", email = "eugen@papermerge.com" } From 29ce75a0be0ce91befeb62e7762ace990ee71150 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Fri, 22 Aug 2025 07:00:13 +0200 Subject: [PATCH 02/40] Frontend: add basic components --- .../ui/public/localization/de/_default.json | 3 +- .../ui/public/localization/en/_default.json | 3 +- .../apps/ui/src/components/NavBar/NavBar.tsx | 33 ++++++++++++++----- .../src/features/audit/components/Details.tsx | 7 ++++ .../ui/src/features/audit/components/List.tsx | 3 ++ .../src/features/audit/components/index.tsx | 4 +++ .../ui/src/features/audit/pages/Details.tsx | 8 +++++ .../apps/ui/src/features/audit/pages/List.tsx | 5 +++ .../ui/src/features/audit/pages/index.tsx | 4 +++ frontend/apps/ui/src/router.tsx | 9 +++++ frontend/apps/ui/src/scopes.ts | 4 ++- papermerge/core/features/auth/scopes.py | 4 +++ 12 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 frontend/apps/ui/src/features/audit/components/Details.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/List.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/index.tsx create mode 100644 frontend/apps/ui/src/features/audit/pages/Details.tsx create mode 100644 frontend/apps/ui/src/features/audit/pages/List.tsx create mode 100644 frontend/apps/ui/src/features/audit/pages/index.tsx diff --git a/frontend/apps/ui/public/localization/de/_default.json b/frontend/apps/ui/public/localization/de/_default.json index 91262da78..1d35ffed2 100644 --- a/frontend/apps/ui/public/localization/de/_default.json +++ b/frontend/apps/ui/public/localization/de/_default.json @@ -265,5 +265,6 @@ "notifications.common.error": "Fehler", "notifications.role.updated.success": "Rolle wurde aktualisiert", "notifications.role.created.success": "Rolle wurde erstellt", - "notifications.role.deleted.success": "Rolle wurde gelöscht" + "notifications.role.deleted.success": "Rolle wurde gelöscht", + "audit_log.name": "Audit-Protokolle" } diff --git a/frontend/apps/ui/public/localization/en/_default.json b/frontend/apps/ui/public/localization/en/_default.json index 963f6a565..4e7e0cc14 100644 --- a/frontend/apps/ui/public/localization/en/_default.json +++ b/frontend/apps/ui/public/localization/en/_default.json @@ -265,5 +265,6 @@ "notifications.common.error": "Error", "notifications.role.updated.success": "Role successfully updated", "notifications.role.created.success": "Role successfully created", - "notifications.role.deleted.success": "Role successfully deleted" + "notifications.role.deleted.success": "Role successfully deleted", + "audit_log.name": "Audit Logs" } diff --git a/frontend/apps/ui/src/components/NavBar/NavBar.tsx b/frontend/apps/ui/src/components/NavBar/NavBar.tsx index c47e99184..c071d2985 100644 --- a/frontend/apps/ui/src/components/NavBar/NavBar.tsx +++ b/frontend/apps/ui/src/components/NavBar/NavBar.tsx @@ -1,4 +1,4 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks" +import {useAppDispatch, useAppSelector} from "@/app/hooks" import PanelContext from "@/contexts/PanelContext" import { commanderViewOptionUpdated, @@ -9,6 +9,7 @@ import { selectNavBarCollapsed } from "@/features/ui/uiSlice" import { + AUDIT_LOG_VIEW, CUSTOM_FIELD_VIEW, DOCUMENT_TYPE_VIEW, GROUP_VIEW, @@ -23,12 +24,13 @@ import { selectCurrentUserError, selectCurrentUserStatus } from "@/slices/currentUser.ts" -import { Center, Group, Loader, Text } from "@mantine/core" +import {Center, Group, Loader, Text} from "@mantine/core" import { IconAlignJustified, IconCategory, IconHome, IconInbox, + IconLogs, IconMasksTheater, IconTag, IconTriangleSquareCircle, @@ -36,13 +38,13 @@ import { IconUsersGroup, IconUserShare } from "@tabler/icons-react" -import { useContext } from "react" -import { useSelector } from "react-redux" -import { NavLink } from "react-router-dom" +import {useContext} from "react" +import {useSelector} from "react-redux" +import {NavLink} from "react-router-dom" -import { useGetVersionQuery } from "@/features/version/apiSlice" -import type { User } from "@/types.ts" -import { useTranslation } from "react-i18next" +import {useGetVersionQuery} from "@/features/version/apiSlice" +import type {User} from "@/types.ts" +import {useTranslation} from "react-i18next" function NavBarFull() { const {t} = useTranslation() @@ -147,6 +149,11 @@ function NavBarFull() { {NavLinkWithFeedback(t("roles.name"), )} )} + {user.scopes.includes(AUDIT_LOG_VIEW) && ( + + {NavLinkWithFeedback(t("audit_log.name"), )} + + )}
@@ -242,6 +249,11 @@ function NavBarCollapsed() { {NavLinkWithFeedbackShort()} )} + {user.scopes.includes(AUDIT_LOG_VIEW) && ( + + {NavLinkWithFeedbackShort()} + + )}
@@ -269,7 +281,10 @@ type NavLinkState = { type ResponsiveLink = ({isActive, isPending}: NavLinkState) => React.JSX.Element -function NavLinkWithFeedback(text: string, icon: React.JSX.Element): ResponsiveLink { +function NavLinkWithFeedback( + text: string, + icon: React.JSX.Element +): ResponsiveLink { return ({isActive, isPending}) => { if (isActive) { return ( diff --git a/frontend/apps/ui/src/features/audit/components/Details.tsx b/frontend/apps/ui/src/features/audit/components/Details.tsx new file mode 100644 index 000000000..e85d33d45 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/Details.tsx @@ -0,0 +1,7 @@ +interface Args { + id: string +} + +export default function AuditLogDetails({id}: Args) { + return <> +} diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx new file mode 100644 index 000000000..8b1874d20 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -0,0 +1,3 @@ +export default function AuditLogsList() { + return <> +} diff --git a/frontend/apps/ui/src/features/audit/components/index.tsx b/frontend/apps/ui/src/features/audit/components/index.tsx new file mode 100644 index 000000000..f5a82710e --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/index.tsx @@ -0,0 +1,4 @@ +import AuditLogDetails from "./Details" +import AuditLogsList from "./List" + +export {AuditLogDetails, AuditLogsList} diff --git a/frontend/apps/ui/src/features/audit/pages/Details.tsx b/frontend/apps/ui/src/features/audit/pages/Details.tsx new file mode 100644 index 000000000..b24a4b0aa --- /dev/null +++ b/frontend/apps/ui/src/features/audit/pages/Details.tsx @@ -0,0 +1,8 @@ +import {AuditLogDetails} from "@/features/audit/components" +import {useParams} from "react-router" + +export default function AuditLogDetailsPage() { + const {auditLogId} = useParams() + + return +} diff --git a/frontend/apps/ui/src/features/audit/pages/List.tsx b/frontend/apps/ui/src/features/audit/pages/List.tsx new file mode 100644 index 000000000..8e7fdf190 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/pages/List.tsx @@ -0,0 +1,5 @@ +import {AuditLogsList} from "@/features/audit/components" + +export default function AuditLogsListPage() { + return +} diff --git a/frontend/apps/ui/src/features/audit/pages/index.tsx b/frontend/apps/ui/src/features/audit/pages/index.tsx new file mode 100644 index 000000000..f5a82710e --- /dev/null +++ b/frontend/apps/ui/src/features/audit/pages/index.tsx @@ -0,0 +1,4 @@ +import AuditLogDetails from "./Details" +import AuditLogsList from "./List" + +export {AuditLogDetails, AuditLogsList} diff --git a/frontend/apps/ui/src/router.tsx b/frontend/apps/ui/src/router.tsx index 245656663..318a44bdf 100644 --- a/frontend/apps/ui/src/router.tsx +++ b/frontend/apps/ui/src/router.tsx @@ -30,6 +30,7 @@ import SharedNodesListView, { import {TagDetails, TagsList} from "@/features/tags/pages" import {UserDetails, UsersList} from "@/features/users/pages" import Document from "@/pages/Document" +import {AuditLogDetails, AuditLogsList} from "./features/audit/pages" import {AccessForbidden, NotFound, UnprocessableContent} from "@/pages/errors" @@ -145,6 +146,14 @@ const router = createBrowserRouter([ path: "/users/:userId", element: }, + { + path: "/audit-logs/", + element: + }, + { + path: "/audit-logs/:auditLogId", + element: + }, { path: ERRORS_403_ACCESS_FORBIDDEN, element: diff --git a/frontend/apps/ui/src/scopes.ts b/frontend/apps/ui/src/scopes.ts index 76a02b016..511c1cdfd 100644 --- a/frontend/apps/ui/src/scopes.ts +++ b/frontend/apps/ui/src/scopes.ts @@ -45,6 +45,7 @@ export const DOCUMENT_TYPE_CREATE = "document_type.create" export const DOCUMENT_TYPE_VIEW = "document_type.view" export const DOCUMENT_TYPE_UPDATE = "document_type.update" export const DOCUMENT_TYPE_DELETE = "document_type.delete" +export const AUDIT_LOG_VIEW = "audit_log.view" export const ALL_PERMS = [ DOCUMENT_DOWNLOAD, @@ -85,5 +86,6 @@ export const ALL_PERMS = [ NODE_UPDATE, NODE_DELETE, TASK_OCR, - OCRLANG_VIEW + OCRLANG_VIEW, + AUDIT_LOG_VIEW ] diff --git a/papermerge/core/features/auth/scopes.py b/papermerge/core/features/auth/scopes.py index e0f14f91b..5e0a59106 100644 --- a/papermerge/core/features/auth/scopes.py +++ b/papermerge/core/features/auth/scopes.py @@ -19,6 +19,7 @@ class ScopeCategory(Enum): CUSTOM_FIELD = "custom_field" DOCUMENT_TYPE = "document_type" SHARED_NODE = "shared_node" + AUDIT_LOG = "audit_log" class Action(Enum): @@ -125,6 +126,9 @@ class Scopes: SHARED_NODE_UPDATE = "shared_node.update" SHARED_NODE_DELETE = "shared_node.delete" + # Audit log permissions + AUDIT_LOG_VIEW = "audit_log.view" + @classmethod def all_scopes(cls) -> Set[str]: """Return all available scopes.""" From eaa309852f24f3ceb96b2098dd2df5ec9d9068e6 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Fri, 22 Aug 2025 08:01:48 +0200 Subject: [PATCH 03/40] add endpoints, apislice, types --- frontend/apps/ui/src/features/api/slice.ts | 9 +-- .../apps/ui/src/features/audit/apiSlice.ts | 35 +++++++++++ .../ui/src/features/audit/components/List.tsx | 14 +++++ frontend/apps/ui/src/features/audit/types.ts | 11 ++++ papermerge/app.py | 4 ++ papermerge/core/dbapi.py | 6 +- papermerge/core/features/audit/db/api.py | 45 ++++++++++++++ papermerge/core/features/audit/db/orm.py | 8 +-- papermerge/core/features/audit/router.py | 58 +++++++++++++++++++ papermerge/core/features/audit/schema.py | 19 ++++++ papermerge/core/features/audit/types.py | 7 +++ papermerge/core/orm.py | 5 +- papermerge/core/schema.py | 4 +- 13 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 frontend/apps/ui/src/features/audit/apiSlice.ts create mode 100644 frontend/apps/ui/src/features/audit/types.ts create mode 100644 papermerge/core/features/audit/db/api.py create mode 100644 papermerge/core/features/audit/router.py create mode 100644 papermerge/core/features/audit/schema.py create mode 100644 papermerge/core/features/audit/types.py diff --git a/frontend/apps/ui/src/features/api/slice.ts b/frontend/apps/ui/src/features/api/slice.ts index 0e094d461..e75c93726 100644 --- a/frontend/apps/ui/src/features/api/slice.ts +++ b/frontend/apps/ui/src/features/api/slice.ts @@ -1,7 +1,7 @@ -import { getBaseURL } from "@/utils" -import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react" +import {getBaseURL} from "@/utils" +import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react" -import type { RootState } from "@/app/types" +import type {RootState} from "@/app/types" const getKeepUnusedDataFor = function () { const keep_unused_data_for = import.meta.env.VITE_KEEP_UNUSED_DATA_FOR @@ -73,7 +73,8 @@ export const apiSlice = createApi({ "DocumentCustomField", // custom fields associated to specific document (via document type) "DocumentCFV", "DocVersList", - "DocumentVersion" + "DocumentVersion", + "AuditLog" ], endpoints: _ => ({}) }) diff --git a/frontend/apps/ui/src/features/audit/apiSlice.ts b/frontend/apps/ui/src/features/audit/apiSlice.ts new file mode 100644 index 000000000..f6eaafb5e --- /dev/null +++ b/frontend/apps/ui/src/features/audit/apiSlice.ts @@ -0,0 +1,35 @@ +import {apiSlice} from "@/features/api/slice" +import type {Paginated, PaginatedArgs} from "@/types" +import type {AuditLog} from "./types" + +import {PAGINATION_DEFAULT_ITEMS_PER_PAGES} from "@/cconstants" + +export const apiSliceWithAuditLogs = apiSlice.injectEndpoints({ + endpoints: builder => ({ + getPaginatedAuditLogs: builder.query< + Paginated, + PaginatedArgs | void + >({ + query: ({ + page_number = 1, + page_size = PAGINATION_DEFAULT_ITEMS_PER_PAGES + }: PaginatedArgs) => + `/audit-logs/?page_number=${page_number}&page_size=${page_size}`, + providesTags: ( + result = {page_number: 1, page_size: 1, num_pages: 1, items: []}, + _error, + _arg + ) => [ + "AuditLog", + ...result.items.map(({id}) => ({type: "AuditLog", id}) as const) + ] + }), + getAuditLog: builder.query({ + query: auditLogID => `/audit-logs/${auditLogID}`, + providesTags: (_result, _error, arg) => [{type: "AuditLog", id: arg}] + }) + }) +}) + +export const {useGetPaginatedAuditLogsQuery, useGetAuditLogQuery} = + apiSliceWithAuditLogs diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 8b1874d20..c2ce1fb06 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,3 +1,17 @@ +import {useState} from "react" +import {useGetPaginatedAuditLogsQuery} from "../apiSlice" + export default function AuditLogsList() { + const lastPageSize = 15 + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(lastPageSize) + + const {data, isLoading, isFetching, isError, error} = + useGetPaginatedAuditLogsQuery({ + page_number: page, + page_size: pageSize + }) + + console.log(data?.items) return <> } diff --git a/frontend/apps/ui/src/features/audit/types.ts b/frontend/apps/ui/src/features/audit/types.ts new file mode 100644 index 000000000..2253e0587 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/types.ts @@ -0,0 +1,11 @@ +export type AuditOperation = "INSERT" | "UPDATE" | "DELETE" | "TRUNCATE" + +export type AuditLog = { + id: string + table_name: string + record_id: string + operation: AuditOperation + timestamp: string + user_id: string + username: string +} diff --git a/papermerge/app.py b/papermerge/app.py index 90befe00f..7816d7781 100644 --- a/papermerge/app.py +++ b/papermerge/app.py @@ -35,6 +35,9 @@ from papermerge.core.features.shared_nodes.router_documents import ( router as shared_document_router, ) +from papermerge.core.features.audit.router import ( + router as audit_log_router +) from papermerge.core.routers.version import ( router as version_router, ) @@ -79,6 +82,7 @@ app.include_router(probe_router, prefix=prefix) app.include_router(tasks_router, prefix=prefix) app.include_router(version_router, prefix=prefix) +app.include_router(audit_log_router, prefix=prefix) if config.papermerge__search__url: app.include_router(search_router, prefix=prefix) diff --git a/papermerge/core/dbapi.py b/papermerge/core/dbapi.py index fa1206aa0..7efaa9941 100644 --- a/papermerge/core/dbapi.py +++ b/papermerge/core/dbapi.py @@ -64,6 +64,7 @@ update_shared_node_access, get_shared_folder ) +from .features.audit.db.api import (get_audit_logs, get_audit_log) __all__ = [ "get_nodes", @@ -124,5 +125,8 @@ "create_shared_nodes", "get_shared_node_access_details", "update_shared_node_access", - "get_document_last_version" + "get_document_last_version", + # audit logs + "get_audit_logs", + "get_audit_log" ] diff --git a/papermerge/core/features/audit/db/api.py b/papermerge/core/features/audit/db/api.py new file mode 100644 index 000000000..ba9d3efa0 --- /dev/null +++ b/papermerge/core/features/audit/db/api.py @@ -0,0 +1,45 @@ +import logging +import math +import uuid + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from papermerge.core import schema, orm + +logger = logging.getLogger(__name__) + + +async def get_audit_log( + db_session: AsyncSession, + audit_log_id: uuid.UUID +) -> schema.AuditLog: + + stmt = ( + select(orm.AuditLog) + .where(orm.AuditLog.id == audit_log_id) + ) + db_item = (await db_session.scalars(stmt)).unique().one() + + result = schema.AuditLog.model_validate(db_item) + + return result + + +async def get_audit_logs( + db_session: AsyncSession, *, page_size: int, page_number: int +) -> schema.PaginatedResponse[schema.AuditLog]: + stmt_total_users = select(func.count(orm.AuditLog.id)) + total_roles = (await db_session.execute(stmt_total_users)).scalar() + + offset = page_size * (page_number - 1) + stmt = select(orm.AuditLog).limit(page_size).offset(offset) + + db_audit_logs = (await db_session.scalars(stmt)).all() + items = [schema.AuditLog.model_validate(db_role) for db_role in db_audit_logs] + + total_pages = math.ceil(total_roles / page_size) + + return schema.PaginatedResponse[schema.AuditLog]( + items=items, page_size=page_size, page_number=page_number, num_pages=total_pages + ) diff --git a/papermerge/core/features/audit/db/orm.py b/papermerge/core/features/audit/db/orm.py index 715b66211..25db5cd76 100644 --- a/papermerge/core/features/audit/db/orm.py +++ b/papermerge/core/features/audit/db/orm.py @@ -1,6 +1,5 @@ from uuid import UUID from datetime import datetime -from enum import Enum from typing import Optional, Dict, Any from sqlalchemy import func, String, Text, Index @@ -8,14 +7,9 @@ from sqlalchemy.dialects.postgresql import TIMESTAMP, JSONB, UUID as PG_UUID from papermerge.core.db.base import Base +from papermerge.core.features.audit.types import AuditOperation -class AuditOperation(str, Enum): - INSERT = "INSERT" - UPDATE = "UPDATE" - DELETE = "DELETE" - TRUNCATE = "TRUNCATE" - class AuditLog(Base): """ Universal audit table for tracking all changes across audited tables. diff --git a/papermerge/core/features/audit/router.py b/papermerge/core/features/audit/router.py new file mode 100644 index 000000000..a0e210954 --- /dev/null +++ b/papermerge/core/features/audit/router.py @@ -0,0 +1,58 @@ +import logging +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Security +from sqlalchemy.exc import NoResultFound +from sqlalchemy.ext.asyncio import AsyncSession + +from papermerge.core import utils, schema, dbapi +from papermerge.core.features.auth import get_current_user +from papermerge.core.features.auth import scopes +from papermerge.core.routers.params import CommonQueryParams +from papermerge.core.db.engine import get_db + +router = APIRouter( + prefix="/audit-logs", + tags=["audit-logs"], +) + +logger = logging.getLogger(__name__) + + + +@router.get("/") +@utils.docstring_parameter(scope=scopes.AUDIT_LOG_VIEW) +async def get_audit_logs( + user: Annotated[schema.User, Security(get_current_user, scopes=[scopes.AUDIT_LOG_VIEW])], + params: CommonQueryParams = Depends(), + db_session: AsyncSession = Depends(get_db), +) -> schema.PaginatedResponse[schema.AuditLog]: + """Get paginated audit logs + + Required scope: `{scope}` + """ + result = await dbapi.get_audit_logs( + db_session, page_size=params.page_size, page_number=params.page_number + ) + + return result + + +@router.get("/{audit_log_id}", response_model=schema.AuditLog) +@utils.docstring_parameter(scope=scopes.AUDIT_LOG_VIEW) +async def get_audit_log( + audit_log_id: uuid.UUID, + user: Annotated[schema.User, Security(get_current_user, scopes=[scopes.AUDIT_LOG_VIEW])], + db_session: AsyncSession = Depends(get_db), +): + """Get audit log entry details + + Required scope: `{scope}` + """ + try: + result = await dbapi.get_audit_log(db_session, audit_log_id=audit_log_id) + except NoResultFound: + raise HTTPException(status_code=404, detail="Audit log entry not found") + + return result diff --git a/papermerge/core/features/audit/schema.py b/papermerge/core/features/audit/schema.py new file mode 100644 index 000000000..1f801ce32 --- /dev/null +++ b/papermerge/core/features/audit/schema.py @@ -0,0 +1,19 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from .types import AuditOperation + + +class AuditLog(BaseModel): + id: uuid.UUID + table_name: str + record_id: uuid.UUID + operation: AuditOperation + timestamp: datetime + user_id: uuid.UUID + username: str + + # Config + model_config = ConfigDict(from_attributes=True) diff --git a/papermerge/core/features/audit/types.py b/papermerge/core/features/audit/types.py new file mode 100644 index 000000000..9280abb1b --- /dev/null +++ b/papermerge/core/features/audit/types.py @@ -0,0 +1,7 @@ +from enum import Enum + +class AuditOperation(str, Enum): + INSERT = "INSERT" + UPDATE = "UPDATE" + DELETE = "DELETE" + TRUNCATE = "TRUNCATE" diff --git a/papermerge/core/orm.py b/papermerge/core/orm.py index ace97574b..dc6d92807 100644 --- a/papermerge/core/orm.py +++ b/papermerge/core/orm.py @@ -7,6 +7,8 @@ from .features.roles.db.orm import Role, Permission, roles_permissions_association from .features.document_types.db.orm import DocumentType, DocumentTypeCustomField from .features.shared_nodes.db.orm import SharedNode +from .features.audit.db.orm import AuditLog + __all__ = [ 'User', @@ -26,5 +28,6 @@ 'Permission', 'DocumentType', 'DocumentTypeCustomField', - 'SharedNode' + 'SharedNode', + 'AuditLog' ] diff --git a/papermerge/core/schema.py b/papermerge/core/schema.py index c399cd1d1..5f9633de9 100644 --- a/papermerge/core/schema.py +++ b/papermerge/core/schema.py @@ -39,6 +39,7 @@ from .features.document_types.schema import DocumentType, UpdateDocumentType, CreateDocumentType from .features.groups.schema import Group, GroupDetails, CreateGroup, UpdateGroup from .features.roles.schema import Role, RoleDetails, CreateRole, UpdateRole, Permission +from .features.audit.schema import AuditLog from .schemas.error import Error, AttrError from .schemas.common import PaginatedResponse from .schemas.version import Version @@ -109,5 +110,6 @@ 'Version', 'Pagination', 'DocVerListItem', - 'DownloadURL' + 'DownloadURL', + 'AuditLog' ] From f039968fa79c105cb50b53290183d26a47f474c4 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Aug 2025 06:21:20 +0200 Subject: [PATCH 04/40] data table component - first draft --- .../src/components/NavBar/NavBar.tsx | 3 + frontend/apps/kommon.dev/src/main.tsx | 4 +- .../apps/kommon.dev/src/pages/DataTable.tsx | 250 ++++++++++++++++ .../src/components/Table/ColumnSelector.tsx | 137 +++++++++ .../kommon/src/components/Table/DataTable.tsx | 275 ++++++++++++++++++ .../src/components/Table/TableFilters.tsx | 208 +++++++++++++ .../src/components/Table/TablePagination.tsx | 69 +++++ .../kommon/src/components/Table/index.tsx | 7 + .../kommon/src/components/Table/types.ts | 64 ++++ .../src/components/Table/useDataTable.ts | 186 ++++++++++++ frontend/packages/kommon/src/index.tsx | 28 +- 11 files changed, 1226 insertions(+), 5 deletions(-) create mode 100644 frontend/apps/kommon.dev/src/pages/DataTable.tsx create mode 100644 frontend/packages/kommon/src/components/Table/ColumnSelector.tsx create mode 100644 frontend/packages/kommon/src/components/Table/DataTable.tsx create mode 100644 frontend/packages/kommon/src/components/Table/TableFilters.tsx create mode 100644 frontend/packages/kommon/src/components/Table/TablePagination.tsx create mode 100644 frontend/packages/kommon/src/components/Table/index.tsx create mode 100644 frontend/packages/kommon/src/components/Table/types.ts create mode 100644 frontend/packages/kommon/src/components/Table/useDataTable.ts diff --git a/frontend/apps/kommon.dev/src/components/NavBar/NavBar.tsx b/frontend/apps/kommon.dev/src/components/NavBar/NavBar.tsx index 698f2c87f..6d622870c 100644 --- a/frontend/apps/kommon.dev/src/components/NavBar/NavBar.tsx +++ b/frontend/apps/kommon.dev/src/components/NavBar/NavBar.tsx @@ -17,6 +17,9 @@ export default function Navbar() { <RoleFormModal /> + + <DataTable /> + ) diff --git a/frontend/apps/kommon.dev/src/main.tsx b/frontend/apps/kommon.dev/src/main.tsx index 979c28add..2c493a211 100644 --- a/frontend/apps/kommon.dev/src/main.tsx +++ b/frontend/apps/kommon.dev/src/main.tsx @@ -4,10 +4,11 @@ import {createRoot} from "react-dom/client" import {BrowserRouter, Route, Routes} from "react-router" import AppShell from "./app/AppShell" import "./index.css" +import DataTablePage from "./pages/DataTable" import EditNodeTitleModal from "./pages/EditNodeTitle" -import SubmitButton from "./pages/SubmitButton" import RoleForm from "./pages/RoleForm" import RoleFormModal from "./pages/RoleFormModal" +import SubmitButton from "./pages/SubmitButton" createRoot(document.getElementById("root")!).render( @@ -19,6 +20,7 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> diff --git a/frontend/apps/kommon.dev/src/pages/DataTable.tsx b/frontend/apps/kommon.dev/src/pages/DataTable.tsx new file mode 100644 index 000000000..247799bce --- /dev/null +++ b/frontend/apps/kommon.dev/src/pages/DataTable.tsx @@ -0,0 +1,250 @@ +import { + Badge, + Checkbox, + Container, + Group, + Stack, + Text, + Title +} from "@mantine/core" +import {IconClock, IconDatabase, IconUser} from "@tabler/icons-react" +import type {ColumnConfig, PaginatedResponse} from "kommon" +import { + ColumnSelector, + DataTable, + TableFilters, + TablePagination, + useTableData +} from "kommon" +import {useState} from "react" + +interface AuditLogItem { + id: string + table_name: string + record_id: string + operation: "INSERT" | "UPDATE" | "DELETE" + timestamp: string + user_id: string + username: string +} + +export default function DataTablePage() { + const [inProgress, setInProgress] = useState(false) + const {state, actions, updateData, visibleColumns, totalItems} = + useTableData({ + initialData: sampleData, + initialColumns: auditLogColumns + }) + + const toggleIsLoading = () => { + setInProgress(!inProgress) + } + + return ( + + + + + + + {/* Page Header */} +
+ Audit Log + + Track all database operations and changes + +
+ + {/* Filters and Column Selector */} + +
+ +
+ + +
+ + {/* Data Table */} + + + {/* Pagination */} + +
+
+ ); +
+ ) +} + +const sampleData: PaginatedResponse = { + page_size: 15, + page_number: 1, + num_pages: 3, + items: [ + { + id: "319483c6-91a7-4f0d-8b3a-827f6079d9e4", + table_name: "nodes", + record_id: "add7799c-a39d-4c16-bf92-2b19d4792ce5", + operation: "INSERT", + timestamp: "2025-08-20T06:35:10.535760Z", + user_id: "49e78737-7c6e-410f-ae27-315b04bdec69", + username: "admin" + }, + { + id: "ef31f5c5-c141-40ca-8194-4671a7953ae5", + table_name: "nodes", + record_id: "add7799c-a39d-4c16-bf92-2b19d4792ce5", + operation: "UPDATE", + timestamp: "2025-08-20T06:40:08.056195Z", + user_id: "49e78737-7c6e-410f-ae27-315b04bdec69", + username: "admin" + }, + { + id: "e314b451-0347-46fd-8d70-b6e2d4709202", + table_name: "nodes", + record_id: "add7799c-a39d-4c16-bf92-2b19d4792ce5", + operation: "DELETE", + timestamp: "2025-08-21T03:55:49.877671Z", + user_id: "49e78737-7c6e-410f-ae27-315b04bdec69", + username: "admin" + } + ] +} + +const auditLogColumns: ColumnConfig[] = [ + { + key: "timestamp", + label: "Timestamp", + sortable: true, + filterable: false, + width: 180, + render: value => { + const date = new Date(value as string) + return ( + + +
+ {date.toLocaleDateString()} + + {date.toLocaleTimeString()} + +
+
+ ) + } + }, + { + key: "operation", + label: "Operation", + sortable: true, + filterable: true, + width: 100, + render: value => { + const colors: Record = { + INSERT: "green", + UPDATE: "blue", + DELETE: "red" + } + return ( + + {value as string} + + ) + } + }, + { + key: "table_name", + label: "Table", + sortable: true, + filterable: true, + width: 150, + render: value => ( + + + + {value as string} + + + ) + }, + { + key: "record_id", + label: "Record ID", + sortable: false, + filterable: true, + width: 200, + render: value => ( + + {(value as string).substring(0, 8)}... + + ) + }, + { + key: "username", + label: "User", + sortable: true, + filterable: true, + width: 120, + render: value => ( + + + {value as string} + + ) + }, + { + key: "user_id", + label: "User ID", + sortable: false, + filterable: true, + visible: false, // Hidden by default + width: 200, + render: value => ( + + {(value as string).substring(0, 8)}... + + ) + }, + { + key: "id", + label: "Log ID", + sortable: false, + filterable: false, + visible: false, // Hidden by default + width: 200, + render: value => ( + + {(value as string).substring(0, 8)}... + + ) + } +] diff --git a/frontend/packages/kommon/src/components/Table/ColumnSelector.tsx b/frontend/packages/kommon/src/components/Table/ColumnSelector.tsx new file mode 100644 index 000000000..397a36819 --- /dev/null +++ b/frontend/packages/kommon/src/components/Table/ColumnSelector.tsx @@ -0,0 +1,137 @@ +// components/ColumnSelector/ColumnSelector.tsx +import { + Button, + Checkbox, + Divider, + Group, + Popover, + ScrollArea, + Stack, + Text +} from "@mantine/core" +import {IconColumns, IconEye, IconEyeOff} from "@tabler/icons-react" +import {useState} from "react" +import {ColumnConfig} from "./types" + +interface ColumnSelectorProps { + columns: ColumnConfig[] + onColumnsChange: (columns: ColumnConfig[]) => void + onToggleColumn?: (columnKey: keyof T) => void +} + +export default function ColumnSelector({ + columns, + onColumnsChange, + onToggleColumn +}: ColumnSelectorProps) { + const [opened, setOpened] = useState(false) + + const visibleCount = columns.filter(col => col.visible !== false).length + const totalCount = columns.length + + const handleToggle = (columnKey: keyof T) => { + if (onToggleColumn) { + onToggleColumn(columnKey) + } else { + const newColumns = columns.map(col => + col.key === columnKey ? {...col, visible: !col.visible} : col + ) + onColumnsChange(newColumns) + } + } + + const showAll = () => { + const newColumns = columns.map(col => ({...col, visible: true})) + onColumnsChange(newColumns) + } + + const hideAll = () => { + const newColumns = columns.map(col => ({...col, visible: false})) + onColumnsChange(newColumns) + } + + const resetToDefault = () => { + const newColumns = columns.map(col => ({ + ...col, + visible: col.visible !== false // Reset to initial state + })) + onColumnsChange(newColumns) + } + + return ( + + + + + + + + + Column Visibility + + + + + + + + + + + + {columns.map(column => ( + handleToggle(column.key)} + size="sm" + /> + ))} + + + + {columns.length > 5 && ( + <> + + + + )} + + + ) +} diff --git a/frontend/packages/kommon/src/components/Table/DataTable.tsx b/frontend/packages/kommon/src/components/Table/DataTable.tsx new file mode 100644 index 000000000..880c2676a --- /dev/null +++ b/frontend/packages/kommon/src/components/Table/DataTable.tsx @@ -0,0 +1,275 @@ +// components/DataTable/DataTable.tsx +import { + ActionIcon, + Box, + Group, + LoadingOverlay, + ScrollArea, + Skeleton, + Table, + Text +} from "@mantine/core" +import { + IconChevronDown, + IconChevronUp, + IconGripVertical, + IconSelector +} from "@tabler/icons-react" +import React, {useEffect, useRef} from "react" +import {ColumnConfig, SortState} from "./types" +import {useColumnResize} from "./useDataTable" + +interface DataTableProps { + data: T[] + columns: ColumnConfig[] + sorting: SortState + onSortChange: (sort: SortState) => void + columnWidths: Record + onColumnResize: (columnKey: string, width: number) => void + loading?: boolean + emptyMessage?: string +} + +export default function DataTable({ + data, + columns, + sorting, + onSortChange, + columnWidths, + onColumnResize, + loading = false, + emptyMessage = "No data available" +}: DataTableProps) { + const tableRef = useRef(null) + const {isResizing, startResize, stopResize, getNewWidth} = useColumnResize() + + // Only show visible columns + const visibleColumns = columns.filter(col => col.visible !== false) + + useEffect(() => { + if (!isResizing) return + + const handleMouseMove = (e: MouseEvent) => { + if (isResizing) { + const newWidth = getNewWidth(e.clientX) + onColumnResize(isResizing, newWidth) + } + } + + const handleMouseUp = () => { + stopResize() + } + + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) + + return () => { + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + } + }, [isResizing, getNewWidth, onColumnResize, stopResize]) + + const handleSort = (columnKey: string) => { + const column = columns.find(col => col.key === columnKey) + if (!column?.sortable) return + + let newDirection: "asc" | "desc" | null = "asc" + + if (sorting.column === columnKey) { + if (sorting.direction === "asc") { + newDirection = "desc" + } else if (sorting.direction === "desc") { + newDirection = null + } + } + + onSortChange({ + column: newDirection ? columnKey : null, + direction: newDirection + }) + } + + const getSortIcon = (columnKey: string) => { + if (sorting.column !== columnKey) { + return + } + return sorting.direction === "asc" ? ( + + ) : ( + + ) + } + + const getColumnWidth = (column: ColumnConfig) => { + const customWidth = columnWidths[String(column.key)] + if (customWidth) return customWidth + if (column.width) return column.width + return 150 // Default width + } + + const handleResizeStart = (e: React.MouseEvent, columnKey: string) => { + e.preventDefault() + const currentWidth = getColumnWidth( + columns.find(col => col.key === columnKey)! + ) + startResize(columnKey, e.clientX, currentWidth) + } + + if (loading && data.length === 0) { + return ( + + + + + + {visibleColumns.map(column => ( + + + + ))} + + + + {Array.from({length: 5}).map((_, index) => ( + + {visibleColumns.map(column => ( + + + + ))} + + ))} + +
+
+ ) + } + + return ( + + + + + + + {visibleColumns.map(column => { + const width = getColumnWidth(column) + return ( + + + + column.sortable && handleSort(String(column.key)) + } + > + + {column.label} + + {column.sortable && ( + + {getSortIcon(String(column.key))} + + )} + + + {/* Resize handle */} +
+ handleResizeStart(e, String(column.key)) + } + > + +
+
+
+ ) + })} +
+
+ + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row, index) => ( + + {visibleColumns.map(column => { + const value = row[column.key] + const width = getColumnWidth(column) + + return ( + + {column.render + ? column.render(value, row) + : String(value)} + + ) + })} + + )) + )} + +
+
+
+ ) +} diff --git a/frontend/packages/kommon/src/components/Table/TableFilters.tsx b/frontend/packages/kommon/src/components/Table/TableFilters.tsx new file mode 100644 index 000000000..aa5fb864b --- /dev/null +++ b/frontend/packages/kommon/src/components/Table/TableFilters.tsx @@ -0,0 +1,208 @@ +// components/TableFilters/TableFilters.tsx +import { + Button, + Group, + MultiSelect, + Paper, + Select, + TextInput +} from "@mantine/core" +import {IconFilter, IconFilterOff, IconSearch} from "@tabler/icons-react" +import {ColumnConfig, FilterValue} from "./types" + +interface TableFiltersProps { + columns: ColumnConfig[] + filters: FilterValue[] + onFiltersChange: (filters: FilterValue[]) => void +} + +export default function TableFilters({ + columns, + filters, + onFiltersChange +}: TableFiltersProps) { + const filterableColumns = columns.filter( + col => col.filterable && col.visible !== false + ) + + const addFilter = () => { + const availableColumns = filterableColumns.filter( + col => !filters.some(filter => filter.column === String(col.key)) + ) + + if (availableColumns.length > 0) { + const newFilter: FilterValue = { + column: String(availableColumns[0].key), + value: "", + operator: "contains" + } + onFiltersChange([...filters, newFilter]) + } + } + + const updateFilter = (index: number, updates: Partial) => { + const newFilters = filters.map((filter, i) => + i === index ? {...filter, ...updates} : filter + ) + onFiltersChange(newFilters) + } + + const removeFilter = (index: number) => { + onFiltersChange(filters.filter((_, i) => i !== index)) + } + + const clearAllFilters = () => { + onFiltersChange([]) + } + + const getOperatorOptions = (columnKey: string) => { + // You can customize operators based on column type + return [ + {value: "contains", label: "Contains"}, + {value: "equals", label: "Equals"}, + {value: "startsWith", label: "Starts with"}, + {value: "endsWith", label: "Ends with"} + ] + } + + const getUniqueValues = (columnKey: string): string[] => { + // This would typically come from your data or API + // For demo purposes, returning some sample values based on column + switch (columnKey) { + case "operation": + return ["INSERT", "UPDATE", "DELETE"] + case "table_name": + return ["nodes", "document_versions", "roles", "custom_fields"] + default: + return [] + } + } + + const renderFilterInput = (filter: FilterValue, index: number) => { + const uniqueValues = getUniqueValues(filter.column) + + if (uniqueValues.length > 0 && uniqueValues.length <= 20) { + // Use select for columns with limited unique values + return ( + ({ + value: String(col.key), + label: col.label + }))} + value={filter.column} + onChange={value => + updateFilter(index, {column: value || "", value: ""}) + } + style={{minWidth: 150}} + /> + + ({ + value: String(size), + label: String(size) + }))} + value={String(pageSize)} + onChange={value => value && onPageSizeChange(Number(value))} + w={70} + /> + entries + + )} + + {totalItems && ( + + Showing {startItem} to {endItem} of {totalItems} entries + + )} + + + + + ) +} diff --git a/frontend/packages/kommon/src/components/Table/index.tsx b/frontend/packages/kommon/src/components/Table/index.tsx new file mode 100644 index 000000000..c762066b1 --- /dev/null +++ b/frontend/packages/kommon/src/components/Table/index.tsx @@ -0,0 +1,7 @@ +import ColumnSelector from "./ColumnSelector" +import DataTable from "./DataTable" +import TableFilters from "./TableFilters" +import TablePagination from "./TablePagination" +import useTableData from "./useDataTable" + +export {ColumnSelector, DataTable, TableFilters, TablePagination, useTableData} diff --git a/frontend/packages/kommon/src/components/Table/types.ts b/frontend/packages/kommon/src/components/Table/types.ts new file mode 100644 index 000000000..a1d6d6630 --- /dev/null +++ b/frontend/packages/kommon/src/components/Table/types.ts @@ -0,0 +1,64 @@ +export interface PaginatedResponse { + page_size: number + page_number: number + num_pages: number + items: T[] +} + +export interface SortState { + column: string | null + direction: "asc" | "desc" | null +} + +export interface FilterValue { + column: string + value: string | string[] + operator?: "equals" | "contains" | "in" | "startsWith" | "endsWith" +} + +export interface ColumnConfig { + key: keyof T + label: string + sortable?: boolean + filterable?: boolean + visible?: boolean + width?: number + minWidth?: number + maxWidth?: number + render?: (value: T[keyof T], row: T) => React.ReactNode +} + +export interface TableState { + data: T[] + pagination: { + page: number + pageSize: number + totalPages: number + } + sorting: SortState + filters: FilterValue[] + columns: ColumnConfig[] + columnWidths: Record + loading?: boolean +} + +export interface TableActions { + setSorting: (sort: SortState) => void + setFilters: (filters: FilterValue[]) => void + setPage: (page: number) => void + setPageSize: (size: number) => void + setColumns: (columns: ColumnConfig[]) => void + setColumnWidth: (columnKey: string, width: number) => void + toggleColumnVisibility: (columnKey: keyof T) => void +} + +export interface UseTableDataProps { + initialData?: PaginatedResponse + initialColumns: ColumnConfig[] + onDataChange?: (state: { + page: number + pageSize: number + sorting: SortState + filters: FilterValue[] + }) => void +} diff --git a/frontend/packages/kommon/src/components/Table/useDataTable.ts b/frontend/packages/kommon/src/components/Table/useDataTable.ts new file mode 100644 index 000000000..4bccf87a6 --- /dev/null +++ b/frontend/packages/kommon/src/components/Table/useDataTable.ts @@ -0,0 +1,186 @@ +// hooks/useTableData.ts +import {useCallback, useEffect, useState} from "react" +import { + ColumnConfig, + FilterValue, + SortState, + TableActions, + TableState, + UseTableDataProps +} from "./types" + +export default function useTableData({ + initialData, + initialColumns, + onDataChange +}: UseTableDataProps) { + const [state, setState] = useState>(() => ({ + data: initialData?.items || [], + pagination: { + page: initialData?.page_number || 1, + pageSize: initialData?.page_size || 15, + totalPages: initialData?.num_pages || 1 + }, + sorting: {column: null, direction: null}, + filters: [], + columns: initialColumns.map(col => ({ + ...col, + visible: col.visible !== false + })), + columnWidths: {}, + loading: false + })) + + const setSorting = useCallback((sorting: SortState) => { + setState(prev => ({...prev, sorting})) + }, []) + + const setFilters = useCallback((filters: FilterValue[]) => { + setState(prev => ({ + ...prev, + filters, + pagination: {...prev.pagination, page: 1} // Reset to first page when filtering + })) + }, []) + + const setPage = useCallback((page: number) => { + setState(prev => ({ + ...prev, + pagination: {...prev.pagination, page} + })) + }, []) + + const setPageSize = useCallback((pageSize: number) => { + setState(prev => ({ + ...prev, + pagination: { + ...prev.pagination, + pageSize, + page: 1 // Reset to first page when changing page size + } + })) + }, []) + + const setColumns = useCallback((columns: ColumnConfig[]) => { + setState(prev => ({...prev, columns})) + }, []) + + const setColumnWidth = useCallback((columnKey: string, width: number) => { + setState(prev => ({ + ...prev, + columnWidths: {...prev.columnWidths, [columnKey]: width} + })) + }, []) + + const toggleColumnVisibility = useCallback((columnKey: keyof T) => { + setState(prev => ({ + ...prev, + columns: prev.columns.map(col => + col.key === columnKey ? {...col, visible: !col.visible} : col + ) + })) + }, []) + + const updateData = useCallback( + ( + newData: T[], + pagination?: { + page_number: number + page_size: number + num_pages: number + } + ) => { + setState(prev => ({ + ...prev, + data: newData, + pagination: pagination + ? { + page: pagination.page_number, + pageSize: pagination.page_size, + totalPages: pagination.num_pages + } + : prev.pagination, + loading: false + })) + }, + [] + ) + + const setLoading = useCallback((loading: boolean) => { + setState(prev => ({...prev, loading})) + }, []) + + // Notify parent component when state changes that affect data fetching + useEffect(() => { + if (onDataChange) { + onDataChange({ + page: state.pagination.page, + pageSize: state.pagination.pageSize, + sorting: state.sorting, + filters: state.filters + }) + } + }, [ + state.pagination.page, + state.pagination.pageSize, + state.sorting, + state.filters, + onDataChange + ]) + + const actions: TableActions = { + setSorting, + setFilters, + setPage, + setPageSize, + setColumns, + setColumnWidth, + toggleColumnVisibility + } + + return { + state, + actions, + updateData, + setLoading, + // Computed values + visibleColumns: state.columns.filter(col => col.visible !== false), + totalItems: state.pagination.totalPages * state.pagination.pageSize // Approximate + } +} + +// Hook for column resizing +export function useColumnResize() { + const [isResizing, setIsResizing] = useState(null) + const [startX, setStartX] = useState(0) + const [startWidth, setStartWidth] = useState(0) + + const startResize = useCallback( + (columnKey: string, startX: number, startWidth: number) => { + setIsResizing(columnKey) + setStartX(startX) + setStartWidth(startWidth) + }, + [] + ) + + const stopResize = useCallback(() => { + setIsResizing(null) + setStartX(0) + setStartWidth(0) + }, []) + + const getNewWidth = useCallback( + (currentX: number) => { + return Math.max(50, startWidth + (currentX - startX)) // Minimum width of 50px + }, + [startX, startWidth] + ) + + return { + isResizing, + startResize, + stopResize, + getNewWidth + } +} diff --git a/frontend/packages/kommon/src/index.tsx b/frontend/packages/kommon/src/index.tsx index 79e921697..3db464a07 100644 --- a/frontend/packages/kommon/src/index.tsx +++ b/frontend/packages/kommon/src/index.tsx @@ -7,15 +7,35 @@ import type { } from "./components/RoleForm/types" import type {I18NRoleFormModal} from "./components/RoleFormModal/types" -import SubmitButton from "./components/SubmitButton/SubmitButton" import RoleForm from "./components/RoleForm" import RoleFormModal from "./components/RoleFormModal" +import SubmitButton from "./components/SubmitButton/SubmitButton" +import { + ColumnSelector, + DataTable, + TableFilters, + TablePagination, + useTableData +} from "./components/Table" +import type {ColumnConfig, PaginatedResponse} from "./components/Table/types" -export {EditNodeTitleModal, SubmitButton, RoleForm, RoleFormModal} +export { + ColumnSelector, + DataTable, + EditNodeTitleModal, + RoleForm, + RoleFormModal, + SubmitButton, + TableFilters, + TablePagination, + useTableData +} export type { - I18NEditNodeTitleModal, + ColumnConfig, I18NCheckButton, I18NCollapseButton, + I18NEditNodeTitleModal, I18NPermissionTree, - I18NRoleFormModal + I18NRoleFormModal, + PaginatedResponse } From b9891dac4956f9d260cca43dde530e51b8b6725b Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Aug 2025 06:45:34 +0200 Subject: [PATCH 05/40] usage of new data table --- .../ui/src/features/audit/components/List.tsx | 281 +++++++++++++++++- frontend/packages/kommon/src/index.tsx | 11 +- 2 files changed, 282 insertions(+), 10 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index c2ce1fb06..340ffc027 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,17 +1,282 @@ -import {useState} from "react" +import {Badge, Container, Group, Stack, Text} from "@mantine/core" +import {IconClock, IconDatabase, IconUser} from "@tabler/icons-react" +import type {ColumnConfig, FilterValue, SortState} from "kommon" +import {useEffect, useState} from "react" import {useGetPaginatedAuditLogsQuery} from "../apiSlice" +import { + ColumnSelector, + DataTable, + TableFilters, + TablePagination, + useTableData +} from "kommon" + export default function AuditLogsList() { - const lastPageSize = 15 + const lastPageSize = 5 const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(lastPageSize) + const [sorting, setSorting] = useState({ + column: null, + direction: null + }) + const [filters, setFilters] = useState([]) + + // Build API params + const apiParams = buildApiParams(page, pageSize, sorting, filters) + + // RTK Query const {data, isLoading, isFetching, isError, error} = - useGetPaginatedAuditLogsQuery({ - page_number: page, - page_size: pageSize - }) + useGetPaginatedAuditLogsQuery(apiParams) + + // Table state management (without pagination - RTK handles that) + const {state, actions, visibleColumns} = useTableData({ + initialData: data || { + items: [], + page_number: 1, + page_size: pageSize, + num_pages: 0 + }, + initialColumns: auditLogColumns + // Don't use onDataChange - RTK handles data fetching + }) + // Update table data when RTK data changes + useEffect(() => { + if (data) { + // Update only the data and columns, keep local sorting/filtering state + actions.setColumns(state.columns) // Keep current column visibility + } + }, [data]) + + // Handle sorting changes (triggers new API call) + const handleSortChange = (newSorting: SortState) => { + setSorting(newSorting) + setPage(1) // Reset to first page when sorting changes + } + + // Handle filter changes (triggers new API call) + const handleFiltersChange = (newFilters: FilterValue[]) => { + setFilters(newFilters) + setPage(1) // Reset to first page when filters change + } + + // Handle page size changes + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize) + setPage(1) // Reset to first page when page size changes + } + + // Calculate total items for pagination display + const totalItems = data ? data.num_pages * data.page_size : 0 + + return ( + + + {/* Filters and Column Selector */} + +
+ +
+ + +
+ + {/* Data Table */} + + + {/* Pagination */} + +
+
+ ) +} + +interface AuditLogItem { + id: string + table_name: string + record_id: string + operation: "INSERT" | "UPDATE" | "DELETE" | "TRUNCATE" + timestamp: string + user_id: string + username: string +} + +const auditLogColumns: ColumnConfig[] = [ + { + key: "timestamp", + label: "Timestamp", + sortable: true, + filterable: false, + width: 180, + render: value => { + const date = new Date(value as string) + return ( + + +
+ {date.toLocaleDateString()} + + {date.toLocaleTimeString()} + +
+
+ ) + } + }, + { + key: "operation", + label: "Operation", + sortable: true, + filterable: true, + width: 100, + render: value => { + const colors: Record = { + INSERT: "green", + UPDATE: "blue", + DELETE: "red" + } + return ( + + {value as string} + + ) + } + }, + { + key: "table_name", + label: "Table", + sortable: true, + filterable: true, + width: 150, + render: value => ( + + + + {value as string} + + + ) + }, + { + key: "record_id", + label: "Record ID", + sortable: false, + filterable: true, + width: 200, + render: value => ( + + {(value as string).substring(0, 8)}... + + ) + }, + { + key: "username", + label: "User", + sortable: true, + filterable: true, + width: 120, + render: value => ( + + + {value as string} + + ) + }, + { + key: "user_id", + label: "User ID", + sortable: false, + filterable: true, + visible: false, // Hidden by default + width: 200, + render: value => ( + + {(value as string).substring(0, 8)}... + + ) + }, + { + key: "id", + label: "Log ID", + sortable: false, + filterable: false, + visible: false, // Hidden by default + width: 200, + render: value => ( + + {(value as string).substring(0, 8)}... + + ) + } +] +interface GetPaginatedAuditLogsParams { + page_number: number + page_size: number + sort_by?: string + sort_direction?: "asc" | "desc" + filters?: Record +} + +function buildApiParams( + page: number, + pageSize: number, + sorting: SortState, + filters: FilterValue[] +): GetPaginatedAuditLogsParams { + const params: GetPaginatedAuditLogsParams = { + page_number: page, + page_size: pageSize + } + + // Add sorting + if (sorting.column && sorting.direction) { + params.sort_by = sorting.column + params.sort_direction = sorting.direction + } + + // Add filters + if (filters.length > 0) { + params.filters = filters.reduce( + (acc, filter) => { + acc[filter.column] = { + value: filter.value, + operator: filter.operator || "contains" + } + return acc + }, + {} as Record + ) + } - console.log(data?.items) - return <> + return params } diff --git a/frontend/packages/kommon/src/index.tsx b/frontend/packages/kommon/src/index.tsx index 3db464a07..3c9fc298c 100644 --- a/frontend/packages/kommon/src/index.tsx +++ b/frontend/packages/kommon/src/index.tsx @@ -17,7 +17,12 @@ import { TablePagination, useTableData } from "./components/Table" -import type {ColumnConfig, PaginatedResponse} from "./components/Table/types" +import type { + ColumnConfig, + FilterValue, + PaginatedResponse, + SortState +} from "./components/Table/types" export { ColumnSelector, @@ -32,10 +37,12 @@ export { } export type { ColumnConfig, + FilterValue, I18NCheckButton, I18NCollapseButton, I18NEditNodeTitleModal, I18NPermissionTree, I18NRoleFormModal, - PaginatedResponse + PaginatedResponse, + SortState } From 05d87279726e8b08d6cb1a7d7df0327808f148d4 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Aug 2025 07:46:07 +0200 Subject: [PATCH 06/40] wip --- .../apps/ui/src/features/audit/apiSlice.ts | 84 +++++- .../ui/src/features/audit/components/List.tsx | 269 ++++++++++++++---- papermerge/core/features/audit/db/api.py | 117 +++++++- papermerge/core/features/audit/db/orm.py | 3 + papermerge/core/features/audit/router.py | 16 +- papermerge/core/features/audit/schema.py | 116 ++++++++ 6 files changed, 529 insertions(+), 76 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/apiSlice.ts b/frontend/apps/ui/src/features/audit/apiSlice.ts index f6eaafb5e..c5c117ea9 100644 --- a/frontend/apps/ui/src/features/audit/apiSlice.ts +++ b/frontend/apps/ui/src/features/audit/apiSlice.ts @@ -1,20 +1,90 @@ +import {PAGINATION_DEFAULT_ITEMS_PER_PAGES} from "@/cconstants" import {apiSlice} from "@/features/api/slice" import type {Paginated, PaginatedArgs} from "@/types" import type {AuditLog} from "./types" -import {PAGINATION_DEFAULT_ITEMS_PER_PAGES} from "@/cconstants" +// Extended query parameters for audit logs +export interface AuditLogQueryParams extends Partial { + // Pagination (inherited from PaginatedArgs) + page_number?: number + page_size?: number + + // Sorting + sort_by?: + | "timestamp" + | "operation" + | "table_name" + | "username" + | "record_id" + | "user_id" + | "id" + sort_direction?: "asc" | "desc" + + // Filters + filter_operation?: "INSERT" | "UPDATE" | "DELETE" + filter_table_name?: string + filter_username?: string + filter_user_id?: string + filter_record_id?: string + filter_timestamp_from?: string // ISO string format + filter_timestamp_to?: string // ISO string format +} + +// Helper function to build clean query string +function buildQueryString(params: AuditLogQueryParams = {}): string { + const searchParams = new URLSearchParams() + + // Always include pagination with defaults + searchParams.append("page_number", String(params.page_number || 1)) + searchParams.append( + "page_size", + String(params.page_size || PAGINATION_DEFAULT_ITEMS_PER_PAGES) + ) + + // Add sorting if provided + if (params.sort_by) { + searchParams.append("sort_by", params.sort_by) + } + if (params.sort_direction) { + searchParams.append("sort_direction", params.sort_direction) + } + + // Add filters if provided + if (params.filter_operation) { + searchParams.append("filter_operation", params.filter_operation) + } + if (params.filter_table_name) { + searchParams.append("filter_table_name", params.filter_table_name) + } + if (params.filter_username) { + searchParams.append("filter_username", params.filter_username) + } + if (params.filter_user_id) { + searchParams.append("filter_user_id", params.filter_user_id) + } + if (params.filter_record_id) { + searchParams.append("filter_record_id", params.filter_record_id) + } + if (params.filter_timestamp_from) { + searchParams.append("filter_timestamp_from", params.filter_timestamp_from) + } + if (params.filter_timestamp_to) { + searchParams.append("filter_timestamp_to", params.filter_timestamp_to) + } + + return searchParams.toString() +} export const apiSliceWithAuditLogs = apiSlice.injectEndpoints({ endpoints: builder => ({ getPaginatedAuditLogs: builder.query< Paginated, - PaginatedArgs | void + AuditLogQueryParams | void >({ - query: ({ - page_number = 1, - page_size = PAGINATION_DEFAULT_ITEMS_PER_PAGES - }: PaginatedArgs) => - `/audit-logs/?page_number=${page_number}&page_size=${page_size}`, + query: (params = {}) => { + const queryString = buildQueryString(params || {}) + return `/audit-logs/?${queryString}` + }, providesTags: ( result = {page_number: 1, page_size: 1, num_pages: 1, items: []}, _error, diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 340ffc027..083d1de89 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,7 +1,8 @@ +import type {PaginatedArgs} from "@/types" import {Badge, Container, Group, Stack, Text} from "@mantine/core" import {IconClock, IconDatabase, IconUser} from "@tabler/icons-react" import type {ColumnConfig, FilterValue, SortState} from "kommon" -import {useEffect, useState} from "react" +import {useCallback, useState} from "react" import {useGetPaginatedAuditLogsQuery} from "../apiSlice" import { @@ -13,62 +14,75 @@ import { } from "kommon" export default function AuditLogsList() { - const lastPageSize = 5 - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(lastPageSize) + // Use the helper hook from the API slice + const auditLogTable = useAuditLogTable() - const [sorting, setSorting] = useState({ - column: null, - direction: null - }) - const [filters, setFilters] = useState([]) - - // Build API params - const apiParams = buildApiParams(page, pageSize, sorting, filters) - - // RTK Query - const {data, isLoading, isFetching, isError, error} = - useGetPaginatedAuditLogsQuery(apiParams) - - // Table state management (without pagination - RTK handles that) + // Table state management const {state, actions, visibleColumns} = useTableData({ - initialData: data || { + initialData: auditLogTable.data || { items: [], page_number: 1, - page_size: pageSize, + page_size: 15, num_pages: 0 }, initialColumns: auditLogColumns - // Don't use onDataChange - RTK handles data fetching }) - // Update table data when RTK data changes - useEffect(() => { - if (data) { - // Update only the data and columns, keep local sorting/filtering state - actions.setColumns(state.columns) // Keep current column visibility - } - }, [data]) - // Handle sorting changes (triggers new API call) - const handleSortChange = (newSorting: SortState) => { - setSorting(newSorting) - setPage(1) // Reset to first page when sorting changes - } + // Convert table filters to API format + const handleFiltersChange = useCallback( + (newFilters: FilterValue[]) => { + const apiFilters: Partial = {} - // Handle filter changes (triggers new API call) - const handleFiltersChange = (newFilters: FilterValue[]) => { - setFilters(newFilters) - setPage(1) // Reset to first page when filters change - } + newFilters.forEach(filter => { + const value = Array.isArray(filter.value) + ? filter.value[0] + : filter.value - // Handle page size changes - const handlePageSizeChange = (newPageSize: number) => { - setPageSize(newPageSize) - setPage(1) // Reset to first page when page size changes - } + switch (filter.column) { + case "operation": + apiFilters.filter_operation = value as + | "INSERT" + | "UPDATE" + | "DELETE" + break + case "table_name": + apiFilters.filter_table_name = value + break + case "username": + apiFilters.filter_username = value + break + case "user_id": + apiFilters.filter_user_id = value + break + case "record_id": + apiFilters.filter_record_id = value + break + } + }) - // Calculate total items for pagination display - const totalItems = data ? data.num_pages * data.page_size : 0 + auditLogTable.setFilters(apiFilters) + }, + [auditLogTable] + ) + + // Handle sorting changes + const handleSortChange = useCallback( + (newSorting: SortState) => { + auditLogTable.setSorting(newSorting.column as any, newSorting.direction) + }, + [auditLogTable] + ) + + if (auditLogTable.isError) { + return ( + +
+

Error loading audit logs

+

{auditLogTable.error?.toString() || "An error occurred"}

+
+
+ ) + } return ( @@ -78,7 +92,7 @@ export default function AuditLogsList() {
@@ -92,24 +106,31 @@ export default function AuditLogsList() { {/* Data Table */} {/* Pagination */} @@ -280,3 +301,141 @@ function buildApiParams( return params } + +export interface AuditLogQueryParams extends Partial { + // Pagination (inherited from PaginatedArgs) + page_number?: number + page_size?: number + + // Sorting + sort_by?: + | "timestamp" + | "operation" + | "table_name" + | "username" + | "record_id" + | "user_id" + | "id" + sort_direction?: "asc" | "desc" + + // Filters + filter_operation?: "INSERT" | "UPDATE" | "DELETE" + filter_table_name?: string + filter_username?: string + filter_user_id?: string + filter_record_id?: string + filter_timestamp_from?: string // ISO string format + filter_timestamp_to?: string // ISO string format +} + +// Helper function to build clean query string +function buildQueryString(params: AuditLogQueryParams): string { + const searchParams = new URLSearchParams() + + // Always include pagination with defaults + searchParams.append("page_number", String(params.page_number || 1)) + searchParams.append("page_size", String(params.page_size || 5)) + + // Add sorting if provided + if (params.sort_by) { + searchParams.append("sort_by", params.sort_by) + } + if (params.sort_direction) { + searchParams.append("sort_direction", params.sort_direction) + } + + // Add filters if provided + if (params.filter_operation) { + searchParams.append("filter_operation", params.filter_operation) + } + if (params.filter_table_name) { + searchParams.append("filter_table_name", params.filter_table_name) + } + if (params.filter_username) { + searchParams.append("filter_username", params.filter_username) + } + if (params.filter_user_id) { + searchParams.append("filter_user_id", params.filter_user_id) + } + if (params.filter_record_id) { + searchParams.append("filter_record_id", params.filter_record_id) + } + if (params.filter_timestamp_from) { + searchParams.append("filter_timestamp_from", params.filter_timestamp_from) + } + if (params.filter_timestamp_to) { + searchParams.append("filter_timestamp_to", params.filter_timestamp_to) + } + + return searchParams.toString() +} + +// Helper hook for table integration (optional but recommended) +export function useAuditLogTable() { + const [queryParams, setQueryParams] = useState({ + page_number: 1, + page_size: 5 + }) + + // RTK Query + const {data, isLoading, isFetching, isError, error} = + useGetPaginatedAuditLogsQuery(queryParams) + + // Helper functions + const setPage = useCallback((page_number: number) => { + setQueryParams(prev => ({...prev, page_number})) + }, []) + + const setPageSize = useCallback((page_size: number) => { + setQueryParams(prev => ({...prev, page_size, page_number: 1})) // Reset to first page + }, []) + + const setSorting = useCallback( + (sort_by: string | null, sort_direction: "asc" | "desc" | null) => { + setQueryParams(prev => ({ + ...prev, + sort_by: sort_by || undefined, + sort_direction: sort_direction || undefined, + page_number: 1 // Reset to first page when sorting changes + })) + }, + [] + ) + + const setFilters = useCallback((filters: Partial) => { + setQueryParams(prev => ({ + ...prev, + ...filters, + page_number: 1 // Reset to first page when filters change + })) + }, []) + + const clearFilters = useCallback(() => { + setQueryParams(prev => ({ + page_number: 1, + page_size: prev.page_size, + sort_by: prev.sort_by, + sort_direction: prev.sort_direction + })) + }, []) + + return { + // Data + data, + isLoading, + isFetching, + isError, + error, + + // Current state + queryParams, + + // Actions + setPage, + setPageSize, + setSorting, + setFilters, + clearFilters, + setQueryParams // Direct access for advanced use + } +} diff --git a/papermerge/core/features/audit/db/api.py b/papermerge/core/features/audit/db/api.py index ba9d3efa0..3dd477f17 100644 --- a/papermerge/core/features/audit/db/api.py +++ b/papermerge/core/features/audit/db/api.py @@ -1,9 +1,11 @@ import logging -import math import uuid +import math +from typing import Optional, Dict, Any +from datetime import datetime -from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, desc, asc from papermerge.core import schema, orm @@ -26,20 +28,115 @@ async def get_audit_log( return result +# Alternative version with more advanced filtering support async def get_audit_logs( - db_session: AsyncSession, *, page_size: int, page_number: int + db_session: AsyncSession, + *, + page_size: int, + page_number: int, + sort_by: Optional[str] = None, + sort_direction: Optional[str] = None, + filters: Optional[Dict[str, Dict[str, Any]]] = None ) -> schema.PaginatedResponse[schema.AuditLog]: - stmt_total_users = select(func.count(orm.AuditLog.id)) - total_roles = (await db_session.execute(stmt_total_users)).scalar() + """ + Advanced version that supports complex filtering with operators. + + Expected filters format: + { + "operation": {"value": "INSERT", "operator": "equals"}, + "table_name": {"value": "nodes", "operator": "contains"}, + "username": {"value": ["admin", "user1"], "operator": "in"} + } + """ + + base_query = select(orm.AuditLog) + count_query = select(func.count(orm.AuditLog.id)) + + # Apply advanced filters + if filters: + filter_conditions = [] + + for column, filter_data in filters.items(): + if not isinstance(filter_data, dict): + continue + + value = filter_data.get("value") + operator = filter_data.get("operator", "contains") + + if value is None: + continue + + column_attr = getattr(orm.AuditLog, column, None) + if column_attr is None: + continue + + # Apply different operators + if operator == "equals": + filter_conditions.append(column_attr == value) + + elif operator == "contains": + filter_conditions.append(column_attr.ilike(f"%{value}%")) + + elif operator == "startsWith": + filter_conditions.append(column_attr.ilike(f"{value}%")) + + elif operator == "endsWith": + filter_conditions.append(column_attr.ilike(f"%{value}")) + + elif operator == "in" and isinstance(value, list): + filter_conditions.append(column_attr.in_(value)) + + # Date range handling for timestamp + elif column == "timestamp" and operator == "range" and isinstance(value, dict): + if "from" in value and value["from"]: + try: + date_from = datetime.fromisoformat(value["from"].replace('Z', '+00:00')) + filter_conditions.append(column_attr >= date_from) + except ValueError: + pass + + if "to" in value and value["to"]: + try: + date_to = datetime.fromisoformat(value["to"].replace('Z', '+00:00')) + filter_conditions.append(column_attr <= date_to) + except ValueError: + pass + + if filter_conditions: + filter_clause = and_(*filter_conditions) + base_query = base_query.where(filter_clause) + count_query = count_query.where(filter_clause) + + # Get total count + total_audit_logs = (await db_session.execute(count_query)).scalar() or 0 + + # Apply sorting + if sort_by and hasattr(orm.AuditLog, sort_by): + column_attr = getattr(orm.AuditLog, sort_by) + if sort_direction == "desc": + base_query = base_query.order_by(desc(column_attr)) + else: + base_query = base_query.order_by(asc(column_attr)) + else: + base_query = base_query.order_by(desc(orm.AuditLog.timestamp)) + # Apply pagination offset = page_size * (page_number - 1) - stmt = select(orm.AuditLog).limit(page_size).offset(offset) + base_query = base_query.limit(page_size).offset(offset) - db_audit_logs = (await db_session.scalars(stmt)).all() - items = [schema.AuditLog.model_validate(db_role) for db_role in db_audit_logs] + # Execute and return + db_audit_logs = (await db_session.scalars(base_query)).all() + items = [] + for db_audit_log in db_audit_logs: + print(db_audit_log) + item = schema.AuditLog.model_validate(db_audit_log) + items.append(item) - total_pages = math.ceil(total_roles / page_size) + total_pages = math.ceil(total_audit_logs / page_size) if total_audit_logs > 0 else 0 return schema.PaginatedResponse[schema.AuditLog]( - items=items, page_size=page_size, page_number=page_number, num_pages=total_pages + items=items, + page_size=page_size, + page_number=page_number, + num_pages=total_pages ) diff --git a/papermerge/core/features/audit/db/orm.py b/papermerge/core/features/audit/db/orm.py index 25db5cd76..d9759de6d 100644 --- a/papermerge/core/features/audit/db/orm.py +++ b/papermerge/core/features/audit/db/orm.py @@ -59,6 +59,9 @@ class AuditLog(Base): Index('idx_audit_log_operation', 'operation'), ) + def __str__(self): + return f"AuditLog(id={self.id}, username={self.username})" + # Helper function to determine what fields changed def get_changed_fields(old_record: dict, new_record: dict) -> tuple[list[str], dict, dict]: diff --git a/papermerge/core/features/audit/router.py b/papermerge/core/features/audit/router.py index a0e210954..ee799b9ac 100644 --- a/papermerge/core/features/audit/router.py +++ b/papermerge/core/features/audit/router.py @@ -9,8 +9,8 @@ from papermerge.core import utils, schema, dbapi from papermerge.core.features.auth import get_current_user from papermerge.core.features.auth import scopes -from papermerge.core.routers.params import CommonQueryParams from papermerge.core.db.engine import get_db +from .schema import AuditLogParams router = APIRouter( prefix="/audit-logs", @@ -20,20 +20,28 @@ logger = logging.getLogger(__name__) - @router.get("/") @utils.docstring_parameter(scope=scopes.AUDIT_LOG_VIEW) async def get_audit_logs( user: Annotated[schema.User, Security(get_current_user, scopes=[scopes.AUDIT_LOG_VIEW])], - params: CommonQueryParams = Depends(), + params: AuditLogParams = Depends(), db_session: AsyncSession = Depends(get_db), ) -> schema.PaginatedResponse[schema.AuditLog]: """Get paginated audit logs Required scope: `{scope}` """ + # Convert to advanced format + advanced_filters = params.to_advanced_filters() + + # Use your advanced database function result = await dbapi.get_audit_logs( - db_session, page_size=params.page_size, page_number=params.page_number + db_session, + page_size=params.page_size, + page_number=params.page_number, + sort_by=params.sort_by, + sort_direction=params.sort_direction, + filters=advanced_filters ) return result diff --git a/papermerge/core/features/audit/schema.py b/papermerge/core/features/audit/schema.py index 1f801ce32..b275958f3 100644 --- a/papermerge/core/features/audit/schema.py +++ b/papermerge/core/features/audit/schema.py @@ -1,6 +1,8 @@ import uuid from datetime import datetime +from typing import Optional, Dict, Any, Literal +from fastapi import Query from pydantic import BaseModel, ConfigDict from .types import AuditOperation @@ -17,3 +19,117 @@ class AuditLog(BaseModel): # Config model_config = ConfigDict(from_attributes=True) + + +class AuditLogParams(BaseModel): + """ + Simple parameter class for audit log queries. + Maps directly to frontend AuditLogQueryParams. + """ + + # Pagination parameters + page_size: int = Query( + 15, + ge=1, + le=100, + description="Number of items per page" + ) + page_number: int = Query( + 1, + ge=1, + description="Page number (1-based)" + ) + + # Sorting parameters + sort_by: Optional[str] = Query( + None, + regex="^(timestamp|operation|table_name|username|record_id|user_id|id)$", + description="Column to sort by: timestamp, operation, table_name, username, record_id, user_id, id" + ) + sort_direction: Optional[Literal["asc", "desc"]] = Query( + None, + description="Sort direction: asc or desc" + ) + + # Filter parameters - individual query parameters + filter_operation: Optional[Literal["INSERT", "UPDATE", "DELETE"]] = Query( + None, + description="Filter by operation type: INSERT, UPDATE, or DELETE" + ) + filter_table_name: Optional[str] = Query( + None, + description="Filter by table name (partial match, case-insensitive)" + ) + filter_username: Optional[str] = Query( + None, + description="Filter by username (partial match, case-insensitive)" + ) + filter_user_id: Optional[str] = Query( + None, + description="Filter by exact user ID match" + ) + filter_record_id: Optional[str] = Query( + None, + description="Filter by record ID (partial match)" + ) + + # Date range filter parameters + filter_timestamp_from: Optional[str] = Query( + None, + description="Filter from timestamp (ISO format: 2025-08-20T06:35:10Z)" + ) + filter_timestamp_to: Optional[str] = Query( + None, + description="Filter to timestamp (ISO format: 2025-08-21T06:35:10Z)" + ) + + def to_advanced_filters(self) -> Optional[Dict[str, Dict[str, Any]]]: + """ + Convert simple filter parameters to advanced filter format + for use with get_audit_logs_advanced function. + """ + filters = {} + + if self.filter_operation: + filters["operation"] = { + "value": self.filter_operation, + "operator": "equals" + } + + if self.filter_table_name: + filters["table_name"] = { + "value": self.filter_table_name, + "operator": "contains" + } + + if self.filter_username: + filters["username"] = { + "value": self.filter_username, + "operator": "contains" + } + + if self.filter_user_id: + filters["user_id"] = { + "value": self.filter_user_id, + "operator": "equals" + } + + if self.filter_record_id: + filters["record_id"] = { + "value": self.filter_record_id, + "operator": "contains" + } + + # Handle timestamp range filtering + if self.filter_timestamp_from or self.filter_timestamp_to: + timestamp_filter = { + "operator": "range", + "value": {} + } + if self.filter_timestamp_from: + timestamp_filter["value"]["from"] = self.filter_timestamp_from + if self.filter_timestamp_to: + timestamp_filter["value"]["to"] = self.filter_timestamp_to + filters["timestamp"] = timestamp_filter + + return filters if filters else None From 1ad6e39ff9a9b6fb76ab4b550325d73714b9b882 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Aug 2025 08:19:14 +0200 Subject: [PATCH 07/40] split components in separate files --- .../ui/src/features/audit/components/List.tsx | 373 +----------------- .../audit/components/auditLogColumns.tsx | 119 ++++++ .../audit/components/useAuditLogTable.ts | 229 +++++++++++ frontend/apps/ui/src/features/audit/types.ts | 10 + .../kommon/src/components/Table/types.ts | 10 +- 5 files changed, 388 insertions(+), 353 deletions(-) create mode 100644 frontend/apps/ui/src/features/audit/components/auditLogColumns.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 083d1de89..7a4b1abd6 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,9 +1,9 @@ -import type {PaginatedArgs} from "@/types" -import {Badge, Container, Group, Stack, Text} from "@mantine/core" -import {IconClock, IconDatabase, IconUser} from "@tabler/icons-react" -import type {ColumnConfig, FilterValue, SortState} from "kommon" -import {useCallback, useState} from "react" -import {useGetPaginatedAuditLogsQuery} from "../apiSlice" +import {Container, Group, Stack} from "@mantine/core" +import type {SortState} from "kommon" +import {useCallback} from "react" +import type {AuditLogItem} from "../types" +import auditLogColumns from "./auditLogColumns" +import useAuditLogTable from "./useAuditLogTable" import { ColumnSelector, @@ -13,8 +13,16 @@ import { useTableData } from "kommon" +type SortBy = + | "timestamp" + | "operation" + | "table_name" + | "username" + | "record_id" + | "user_id" + | "id" + export default function AuditLogsList() { - // Use the helper hook from the API slice const auditLogTable = useAuditLogTable() // Table state management @@ -28,47 +36,13 @@ export default function AuditLogsList() { initialColumns: auditLogColumns }) - // Convert table filters to API format - const handleFiltersChange = useCallback( - (newFilters: FilterValue[]) => { - const apiFilters: Partial = {} - - newFilters.forEach(filter => { - const value = Array.isArray(filter.value) - ? filter.value[0] - : filter.value - - switch (filter.column) { - case "operation": - apiFilters.filter_operation = value as - | "INSERT" - | "UPDATE" - | "DELETE" - break - case "table_name": - apiFilters.filter_table_name = value - break - case "username": - apiFilters.filter_username = value - break - case "user_id": - apiFilters.filter_user_id = value - break - case "record_id": - apiFilters.filter_record_id = value - break - } - }) - - auditLogTable.setFilters(apiFilters) - }, - [auditLogTable] - ) - // Handle sorting changes const handleSortChange = useCallback( (newSorting: SortState) => { - auditLogTable.setSorting(newSorting.column as any, newSorting.direction) + auditLogTable.setSorting( + newSorting.column as SortBy, + newSorting.direction + ) }, [auditLogTable] ) @@ -87,13 +61,12 @@ export default function AuditLogsList() { return ( - {/* Filters and Column Selector */}
@@ -104,7 +77,6 @@ export default function AuditLogsList() { />
- {/* Data Table */} - {/* Pagination */} ) } - -interface AuditLogItem { - id: string - table_name: string - record_id: string - operation: "INSERT" | "UPDATE" | "DELETE" | "TRUNCATE" - timestamp: string - user_id: string - username: string -} - -const auditLogColumns: ColumnConfig[] = [ - { - key: "timestamp", - label: "Timestamp", - sortable: true, - filterable: false, - width: 180, - render: value => { - const date = new Date(value as string) - return ( - - -
- {date.toLocaleDateString()} - - {date.toLocaleTimeString()} - -
-
- ) - } - }, - { - key: "operation", - label: "Operation", - sortable: true, - filterable: true, - width: 100, - render: value => { - const colors: Record = { - INSERT: "green", - UPDATE: "blue", - DELETE: "red" - } - return ( - - {value as string} - - ) - } - }, - { - key: "table_name", - label: "Table", - sortable: true, - filterable: true, - width: 150, - render: value => ( - - - - {value as string} - - - ) - }, - { - key: "record_id", - label: "Record ID", - sortable: false, - filterable: true, - width: 200, - render: value => ( - - {(value as string).substring(0, 8)}... - - ) - }, - { - key: "username", - label: "User", - sortable: true, - filterable: true, - width: 120, - render: value => ( - - - {value as string} - - ) - }, - { - key: "user_id", - label: "User ID", - sortable: false, - filterable: true, - visible: false, // Hidden by default - width: 200, - render: value => ( - - {(value as string).substring(0, 8)}... - - ) - }, - { - key: "id", - label: "Log ID", - sortable: false, - filterable: false, - visible: false, // Hidden by default - width: 200, - render: value => ( - - {(value as string).substring(0, 8)}... - - ) - } -] -interface GetPaginatedAuditLogsParams { - page_number: number - page_size: number - sort_by?: string - sort_direction?: "asc" | "desc" - filters?: Record -} - -function buildApiParams( - page: number, - pageSize: number, - sorting: SortState, - filters: FilterValue[] -): GetPaginatedAuditLogsParams { - const params: GetPaginatedAuditLogsParams = { - page_number: page, - page_size: pageSize - } - - // Add sorting - if (sorting.column && sorting.direction) { - params.sort_by = sorting.column - params.sort_direction = sorting.direction - } - - // Add filters - if (filters.length > 0) { - params.filters = filters.reduce( - (acc, filter) => { - acc[filter.column] = { - value: filter.value, - operator: filter.operator || "contains" - } - return acc - }, - {} as Record - ) - } - - return params -} - -export interface AuditLogQueryParams extends Partial { - // Pagination (inherited from PaginatedArgs) - page_number?: number - page_size?: number - - // Sorting - sort_by?: - | "timestamp" - | "operation" - | "table_name" - | "username" - | "record_id" - | "user_id" - | "id" - sort_direction?: "asc" | "desc" - - // Filters - filter_operation?: "INSERT" | "UPDATE" | "DELETE" - filter_table_name?: string - filter_username?: string - filter_user_id?: string - filter_record_id?: string - filter_timestamp_from?: string // ISO string format - filter_timestamp_to?: string // ISO string format -} - -// Helper function to build clean query string -function buildQueryString(params: AuditLogQueryParams): string { - const searchParams = new URLSearchParams() - - // Always include pagination with defaults - searchParams.append("page_number", String(params.page_number || 1)) - searchParams.append("page_size", String(params.page_size || 5)) - - // Add sorting if provided - if (params.sort_by) { - searchParams.append("sort_by", params.sort_by) - } - if (params.sort_direction) { - searchParams.append("sort_direction", params.sort_direction) - } - - // Add filters if provided - if (params.filter_operation) { - searchParams.append("filter_operation", params.filter_operation) - } - if (params.filter_table_name) { - searchParams.append("filter_table_name", params.filter_table_name) - } - if (params.filter_username) { - searchParams.append("filter_username", params.filter_username) - } - if (params.filter_user_id) { - searchParams.append("filter_user_id", params.filter_user_id) - } - if (params.filter_record_id) { - searchParams.append("filter_record_id", params.filter_record_id) - } - if (params.filter_timestamp_from) { - searchParams.append("filter_timestamp_from", params.filter_timestamp_from) - } - if (params.filter_timestamp_to) { - searchParams.append("filter_timestamp_to", params.filter_timestamp_to) - } - - return searchParams.toString() -} - -// Helper hook for table integration (optional but recommended) -export function useAuditLogTable() { - const [queryParams, setQueryParams] = useState({ - page_number: 1, - page_size: 5 - }) - - // RTK Query - const {data, isLoading, isFetching, isError, error} = - useGetPaginatedAuditLogsQuery(queryParams) - - // Helper functions - const setPage = useCallback((page_number: number) => { - setQueryParams(prev => ({...prev, page_number})) - }, []) - - const setPageSize = useCallback((page_size: number) => { - setQueryParams(prev => ({...prev, page_size, page_number: 1})) // Reset to first page - }, []) - - const setSorting = useCallback( - (sort_by: string | null, sort_direction: "asc" | "desc" | null) => { - setQueryParams(prev => ({ - ...prev, - sort_by: sort_by || undefined, - sort_direction: sort_direction || undefined, - page_number: 1 // Reset to first page when sorting changes - })) - }, - [] - ) - - const setFilters = useCallback((filters: Partial) => { - setQueryParams(prev => ({ - ...prev, - ...filters, - page_number: 1 // Reset to first page when filters change - })) - }, []) - - const clearFilters = useCallback(() => { - setQueryParams(prev => ({ - page_number: 1, - page_size: prev.page_size, - sort_by: prev.sort_by, - sort_direction: prev.sort_direction - })) - }, []) - - return { - // Data - data, - isLoading, - isFetching, - isError, - error, - - // Current state - queryParams, - - // Actions - setPage, - setPageSize, - setSorting, - setFilters, - clearFilters, - setQueryParams // Direct access for advanced use - } -} diff --git a/frontend/apps/ui/src/features/audit/components/auditLogColumns.tsx b/frontend/apps/ui/src/features/audit/components/auditLogColumns.tsx new file mode 100644 index 000000000..c6e467b40 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/auditLogColumns.tsx @@ -0,0 +1,119 @@ +import {Badge, Group, Text} from "@mantine/core" +import {IconClock, IconDatabase, IconUser} from "@tabler/icons-react" +import type {ColumnConfig} from "kommon" +import type {AuditLogItem} from "../types" + +const auditLogColumns: ColumnConfig[] = [ + { + key: "timestamp", + label: "Timestamp", + sortable: true, + filterable: false, + width: 180, + render: value => { + const date = new Date(value as string) + return ( + + +
+ {date.toLocaleDateString()} + + {date.toLocaleTimeString()} + +
+
+ ) + } + }, + { + key: "operation", + label: "Operation", + sortable: true, + filterable: true, + width: 100, + render: value => { + const colors: Record = { + INSERT: "green", + UPDATE: "blue", + DELETE: "red" + } + return ( + + {value as string} + + ) + } + }, + { + key: "table_name", + label: "Table", + sortable: true, + filterable: true, + width: 150, + render: value => ( + + + + {value as string} + + + ) + }, + { + key: "record_id", + label: "Record ID", + sortable: false, + filterable: true, + width: 200, + render: value => ( + + {(value as string).substring(0, 8)}... + + ) + }, + { + key: "username", + label: "User", + sortable: true, + filterable: true, + width: 120, + render: value => ( + + + {value as string} + + ) + }, + { + key: "user_id", + label: "User ID", + sortable: false, + filterable: true, + visible: false, // Hidden by default + width: 200, + render: value => ( + + {(value as string).substring(0, 8)}... + + ) + }, + { + key: "id", + label: "Log ID", + sortable: false, + filterable: false, + visible: false, // Hidden by default + width: 200, + render: value => ( + + {(value as string).substring(0, 8)}... + + ) + } +] + +export default auditLogColumns diff --git a/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts new file mode 100644 index 000000000..9be2a0d2d --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts @@ -0,0 +1,229 @@ +import type {PaginatedArgs} from "@/types" +import type {FilterValue} from "kommon" +import {useCallback, useMemo, useState} from "react" +import {useGetPaginatedAuditLogsQuery} from "../apiSlice" + +type SortBy = + | "timestamp" + | "operation" + | "table_name" + | "username" + | "record_id" + | "user_id" + | "id" + +export interface AuditLogQueryParams extends Partial { + // Pagination (inherited from PaginatedArgs) + page_number?: number + page_size?: number + + // Sorting + sort_by?: SortBy + sort_direction?: "asc" | "desc" + + // Filters + filter_operation?: "INSERT" | "UPDATE" | "DELETE" + filter_table_name?: string + filter_username?: string + filter_user_id?: string + filter_record_id?: string + filter_timestamp_from?: string // ISO string format + filter_timestamp_to?: string // ISO string format +} + +// Enhanced helper hook with filter support +export default function useAuditLogTable() { + const [queryParams, setQueryParams] = useState({ + page_number: 1, + page_size: 5 + }) + + // RTK Query + const {data, isLoading, isFetching, isError, error} = + useGetPaginatedAuditLogsQuery(queryParams) + + // Convert API params to table filters format + const currentFilters = useMemo((): FilterValue[] => { + const filters: FilterValue[] = [] + + if (queryParams.filter_operation) { + filters.push({ + column: "operation", + value: queryParams.filter_operation, + operator: "equals" + }) + } + + if (queryParams.filter_table_name) { + filters.push({ + column: "table_name", + value: queryParams.filter_table_name, + operator: "contains" + }) + } + + if (queryParams.filter_username) { + filters.push({ + column: "username", + value: queryParams.filter_username, + operator: "contains" + }) + } + + if (queryParams.filter_user_id) { + filters.push({ + column: "user_id", + value: queryParams.filter_user_id, + operator: "equals" + }) + } + + if (queryParams.filter_record_id) { + filters.push({ + column: "record_id", + value: queryParams.filter_record_id, + operator: "contains" + }) + } + + // Handle timestamp filters + if (queryParams.filter_timestamp_from && queryParams.filter_timestamp_to) { + filters.push({ + column: "timestamp", + value: `${queryParams.filter_timestamp_from} - ${queryParams.filter_timestamp_to}`, + operator: "range" + }) + } else if (queryParams.filter_timestamp_from) { + filters.push({ + column: "timestamp", + value: queryParams.filter_timestamp_from, + operator: "from" + }) + } else if (queryParams.filter_timestamp_to) { + filters.push({ + column: "timestamp", + value: queryParams.filter_timestamp_to, + operator: "to" + }) + } + + return filters + }, [queryParams]) + + // Helper functions + const setPage = useCallback((page_number: number) => { + setQueryParams(prev => ({...prev, page_number})) + }, []) + + const setPageSize = useCallback((page_size: number) => { + setQueryParams(prev => ({...prev, page_size, page_number: 1})) // Reset to first page + }, []) + + const setSorting = useCallback( + (sort_by: SortBy | null, sort_direction: "asc" | "desc" | null) => { + setQueryParams(prev => ({ + ...prev, + sort_by: sort_by || undefined, + sort_direction: sort_direction || undefined, + page_number: 1 // Reset to first page when sorting changes + })) + }, + [] + ) + + const setFilters = useCallback((filters: Partial) => { + setQueryParams(prev => ({ + ...prev, + ...filters, + page_number: 1 // Reset to first page when filters change + })) + }, []) + + // Convert table filters to API format + const setTableFilters = useCallback( + (newFilters: FilterValue[]) => { + const apiFilters: Partial = { + // Clear existing filters + filter_operation: undefined, + filter_table_name: undefined, + filter_username: undefined, + filter_user_id: undefined, + filter_record_id: undefined, + filter_timestamp_from: undefined, + filter_timestamp_to: undefined + } + + newFilters.forEach(filter => { + const value = Array.isArray(filter.value) + ? filter.value[0] + : filter.value + + switch (filter.column) { + case "operation": + apiFilters.filter_operation = value as + | "INSERT" + | "UPDATE" + | "DELETE" + break + case "table_name": + apiFilters.filter_table_name = value + break + case "username": + apiFilters.filter_username = value + break + case "user_id": + apiFilters.filter_user_id = value + break + case "record_id": + apiFilters.filter_record_id = value + break + case "timestamp": + if (filter.operator === "range" && typeof value === "string") { + const [from, to] = value.split(" - ") + if (from) apiFilters.filter_timestamp_from = from.trim() + if (to) apiFilters.filter_timestamp_to = to.trim() + } else if (filter.operator === "from") { + apiFilters.filter_timestamp_from = value + } else if (filter.operator === "to") { + apiFilters.filter_timestamp_to = value + } + break + } + }) + + setFilters(apiFilters) + }, + [setFilters] + ) + + const clearFilters = useCallback(() => { + setQueryParams(prev => ({ + page_number: 1, + page_size: prev.page_size, + sort_by: prev.sort_by, + sort_direction: prev.sort_direction + })) + }, []) + + return { + // Data + data, + isLoading, + isFetching, + isError, + error, + + // Current state + queryParams, + currentFilters, // ← NEW: Filters in table format + + // Actions + setPage, + setPageSize, + setSorting, + setFilters, // API format + setTableFilters, // Table format (NEW) + clearFilters, + setQueryParams // Direct access for advanced use + } +} diff --git a/frontend/apps/ui/src/features/audit/types.ts b/frontend/apps/ui/src/features/audit/types.ts index 2253e0587..a8d242b63 100644 --- a/frontend/apps/ui/src/features/audit/types.ts +++ b/frontend/apps/ui/src/features/audit/types.ts @@ -9,3 +9,13 @@ export type AuditLog = { user_id: string username: string } + +export interface AuditLogItem { + id: string + table_name: string + record_id: string + operation: "INSERT" | "UPDATE" | "DELETE" | "TRUNCATE" + timestamp: string + user_id: string + username: string +} diff --git a/frontend/packages/kommon/src/components/Table/types.ts b/frontend/packages/kommon/src/components/Table/types.ts index a1d6d6630..de1848d15 100644 --- a/frontend/packages/kommon/src/components/Table/types.ts +++ b/frontend/packages/kommon/src/components/Table/types.ts @@ -13,7 +13,15 @@ export interface SortState { export interface FilterValue { column: string value: string | string[] - operator?: "equals" | "contains" | "in" | "startsWith" | "endsWith" + operator?: + | "equals" + | "contains" + | "startsWith" + | "endsWith" + | "in" + | "range" + | "from" + | "to" } export interface ColumnConfig { From 1c79b8844516d06bf50d0131c81c758d94b8c0c8 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Aug 2025 08:59:38 +0200 Subject: [PATCH 08/40] OKish initial version of data table --- .../ui/src/features/audit/components/List.tsx | 28 +-- .../audit/components/useAuditLogTable.ts | 2 - frontend/apps/ui/src/features/audit/types.ts | 9 + .../src/components/Table/TableFilters.tsx | 176 ++++++++++++++---- 4 files changed, 168 insertions(+), 47 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 7a4b1abd6..1375064c4 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,7 +1,7 @@ import {Container, Group, Stack} from "@mantine/core" import type {SortState} from "kommon" -import {useCallback} from "react" -import type {AuditLogItem} from "../types" +import {useCallback, useEffect} from "react" +import type {AuditLogItem, SortBy} from "../types" import auditLogColumns from "./auditLogColumns" import useAuditLogTable from "./useAuditLogTable" @@ -13,15 +13,6 @@ import { useTableData } from "kommon" -type SortBy = - | "timestamp" - | "operation" - | "table_name" - | "username" - | "record_id" - | "user_id" - | "id" - export default function AuditLogsList() { const auditLogTable = useAuditLogTable() @@ -47,6 +38,21 @@ export default function AuditLogsList() { [auditLogTable] ) + // Debug logging + useEffect(() => { + console.log("=== AUDIT LOGS DEBUG ===") + console.log("auditLogTable.currentFilters:", auditLogTable.currentFilters) + console.log("state.columns:", state.columns) + console.log( + "filterable columns:", + state.columns.filter(col => col.filterable) + ) + console.log( + "visible filterable columns:", + state.columns.filter(col => col.filterable && col.visible !== false) + ) + }, [auditLogTable.currentFilters, state.columns]) + if (auditLogTable.isError) { return ( diff --git a/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts index 9be2a0d2d..789e382f9 100644 --- a/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts +++ b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts @@ -45,7 +45,6 @@ export default function useAuditLogTable() { // Convert API params to table filters format const currentFilters = useMemo((): FilterValue[] => { const filters: FilterValue[] = [] - if (queryParams.filter_operation) { filters.push({ column: "operation", @@ -190,7 +189,6 @@ export default function useAuditLogTable() { break } }) - setFilters(apiFilters) }, [setFilters] diff --git a/frontend/apps/ui/src/features/audit/types.ts b/frontend/apps/ui/src/features/audit/types.ts index a8d242b63..7babaad55 100644 --- a/frontend/apps/ui/src/features/audit/types.ts +++ b/frontend/apps/ui/src/features/audit/types.ts @@ -19,3 +19,12 @@ export interface AuditLogItem { user_id: string username: string } + +export type SortBy = + | "timestamp" + | "operation" + | "table_name" + | "username" + | "record_id" + | "user_id" + | "id" diff --git a/frontend/packages/kommon/src/components/Table/TableFilters.tsx b/frontend/packages/kommon/src/components/Table/TableFilters.tsx index aa5fb864b..bfadda9d4 100644 --- a/frontend/packages/kommon/src/components/Table/TableFilters.tsx +++ b/frontend/packages/kommon/src/components/Table/TableFilters.tsx @@ -14,12 +14,14 @@ interface TableFiltersProps { columns: ColumnConfig[] filters: FilterValue[] onFiltersChange: (filters: FilterValue[]) => void + getUniqueValues?: (columnKey: string) => string[] } export default function TableFilters({ columns, filters, - onFiltersChange + onFiltersChange, + getUniqueValues }: TableFiltersProps) { const filterableColumns = columns.filter( col => col.filterable && col.visible !== false @@ -56,62 +58,168 @@ export default function TableFilters({ } const getOperatorOptions = (columnKey: string) => { - // You can customize operators based on column type - return [ - {value: "contains", label: "Contains"}, - {value: "equals", label: "Equals"}, - {value: "startsWith", label: "Starts with"}, - {value: "endsWith", label: "Ends with"} - ] + // Customize operators based on column type + switch (columnKey) { + case "operation": + return [ + {value: "equals", label: "Equals"}, + {value: "in", label: "Is one of"} + ] + case "timestamp": + return [ + {value: "range", label: "Date range"}, + {value: "from", label: "From date"}, + {value: "to", label: "Until date"} + ] + default: + return [ + {value: "contains", label: "Contains"}, + {value: "equals", label: "Equals"}, + {value: "startsWith", label: "Starts with"}, + {value: "endsWith", label: "Ends with"} + ] + } } - const getUniqueValues = (columnKey: string): string[] => { - // This would typically come from your data or API - // For demo purposes, returning some sample values based on column + // Get unique values for a column - uses the prop or falls back to default logic + const getUniqueValuesForColumn = (columnKey: string): string[] => { + // Use the provided function if available + if (getUniqueValues) { + return getUniqueValues(columnKey) + } + + // Fall back to default/hardcoded values switch (columnKey) { case "operation": - return ["INSERT", "UPDATE", "DELETE"] + return ["INSERT", "UPDATE", "DELETE", "TRUNCATE"] case "table_name": - return ["nodes", "document_versions", "roles", "custom_fields"] + return ["nodes", "document_versions", "roles", "custom_fields", "users"] + case "username": + return ["admin", "user", "system"] default: return [] } } const renderFilterInput = (filter: FilterValue, index: number) => { - const uniqueValues = getUniqueValues(filter.column) + const uniqueValues = getUniqueValuesForColumn(filter.column) - if (uniqueValues.length > 0 && uniqueValues.length <= 20) { - // Use select for columns with limited unique values + // For operation column - always use select + if (filter.column === "operation") { return ( ({value: val, label: val}))} + value={Array.isArray(filter.value) ? filter.value[0] : filter.value} + onChange={value => updateFilter(index, {value: value || ""})} + clearable + searchable + /> + ) + } } - // Default text input + // Default text input for everything else return ( Date: Sat, 23 Aug 2025 10:18:14 +0200 Subject: [PATCH 09/40] Filter component --- .../audit/components/DropdownSelector.tsx | 61 +++++++++++++ .../ui/src/features/audit/components/List.tsx | 48 ++++------ frontend/apps/ui/src/features/audit/types.ts | 6 ++ .../src/components/Table/ColumnSelector.tsx | 90 ++----------------- 4 files changed, 92 insertions(+), 113 deletions(-) create mode 100644 frontend/apps/ui/src/features/audit/components/DropdownSelector.tsx diff --git a/frontend/apps/ui/src/features/audit/components/DropdownSelector.tsx b/frontend/apps/ui/src/features/audit/components/DropdownSelector.tsx new file mode 100644 index 000000000..bf257151b --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/DropdownSelector.tsx @@ -0,0 +1,61 @@ +import {ActionIcon, Checkbox, Popover, ScrollArea, Stack} from "@mantine/core" +import {IconFilter} from "@tabler/icons-react" +import {useState} from "react" +import type {DropdownConfig} from "../types" + +interface FilterSelectorArgs { + initialItems: DropdownConfig[] + onChange: (items: DropdownConfig[]) => void +} + +export default function DropdownSelector({ + initialItems, + onChange +}: FilterSelectorArgs) { + const [opened, setOpened] = useState(false) + const [items, setItems] = useState(initialItems) + + const onLocalChange = (key: DropdownConfig["key"], checked: boolean) => { + const newItems = items.map(i => + i.key === key ? {...i, visible: checked} : i + ) + + setItems(newItems) + onChange(newItems) + } + + return ( + + + setOpened(o => !o)}> + + + + + + + + {items.map(filter => ( + + onLocalChange(filter.key, e.currentTarget.checked) + } + size="sm" + /> + ))} + + + + + ) +} diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 1375064c4..d2cc22cad 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,17 +1,17 @@ import {Container, Group, Stack} from "@mantine/core" import type {SortState} from "kommon" -import {useCallback, useEffect} from "react" -import type {AuditLogItem, SortBy} from "../types" +import {useCallback} from "react" +import type {AuditLogItem, DropdownConfig, SortBy} from "../types" import auditLogColumns from "./auditLogColumns" +import FilterSelector from "./DropdownSelector" import useAuditLogTable from "./useAuditLogTable" -import { - ColumnSelector, - DataTable, - TableFilters, - TablePagination, - useTableData -} from "kommon" +import {ColumnSelector, DataTable, TablePagination, useTableData} from "kommon" + +let filtersConfig = [ + {key: "timestamp", label: "Timestamp", visible: false}, + {key: "user", label: "User", visible: false} +] export default function AuditLogsList() { const auditLogTable = useAuditLogTable() @@ -38,20 +38,9 @@ export default function AuditLogsList() { [auditLogTable] ) - // Debug logging - useEffect(() => { - console.log("=== AUDIT LOGS DEBUG ===") - console.log("auditLogTable.currentFilters:", auditLogTable.currentFilters) - console.log("state.columns:", state.columns) - console.log( - "filterable columns:", - state.columns.filter(col => col.filterable) - ) - console.log( - "visible filterable columns:", - state.columns.filter(col => col.filterable && col.visible !== false) - ) - }, [auditLogTable.currentFilters, state.columns]) + const onFilterVisibilityChange = (items: DropdownConfig[]) => { + console.log(items) + } if (auditLogTable.isError) { return ( @@ -67,14 +56,11 @@ export default function AuditLogsList() { return ( - -
- -
+ + { +interface ColumnSelectorArgs { columns: ColumnConfig[] onColumnsChange: (columns: ColumnConfig[]) => void onToggleColumn?: (columnKey: keyof T) => void @@ -23,12 +14,9 @@ export default function ColumnSelector({ columns, onColumnsChange, onToggleColumn -}: ColumnSelectorProps) { +}: ColumnSelectorArgs) { const [opened, setOpened] = useState(false) - const visibleCount = columns.filter(col => col.visible !== false).length - const totalCount = columns.length - const handleToggle = (columnKey: keyof T) => { if (onToggleColumn) { onToggleColumn(columnKey) @@ -40,27 +28,9 @@ export default function ColumnSelector({ } } - const showAll = () => { - const newColumns = columns.map(col => ({...col, visible: true})) - onColumnsChange(newColumns) - } - - const hideAll = () => { - const newColumns = columns.map(col => ({...col, visible: false})) - onColumnsChange(newColumns) - } - - const resetToDefault = () => { - const newColumns = columns.map(col => ({ - ...col, - visible: col.visible !== false // Reset to initial state - })) - onColumnsChange(newColumns) - } - return ( ({ onChange={setOpened} > - + setOpened(o => !o)}> + + - - - Column Visibility - - - - - - - - - {columns.map(column => ( @@ -117,20 +57,6 @@ export default function ColumnSelector({ ))} - - {columns.length > 5 && ( - <> - - - - )} ) From 29b62ab087ac7006b64e443a35dc251c5ed12650 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Aug 2025 17:55:49 +0200 Subject: [PATCH 10/40] OKish UI --- .../features/audit/components/FilterList.tsx | 1 + .../audit/components/FiltersCollapse.tsx | 91 ++++++++++++++++++ .../ui/src/features/audit/components/List.tsx | 92 ++++++++++--------- .../audit/components/OperationFilter.tsx | 14 +++ .../audit/components/TableNameFilter.tsx | 22 +++++ .../audit/components/TimestampFilter.tsx | 92 +++++++++++++++++++ .../features/audit/components/UserFilter.tsx | 3 + 7 files changed, 271 insertions(+), 44 deletions(-) create mode 100644 frontend/apps/ui/src/features/audit/components/FilterList.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/OperationFilter.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/UserFilter.tsx diff --git a/frontend/apps/ui/src/features/audit/components/FilterList.tsx b/frontend/apps/ui/src/features/audit/components/FilterList.tsx new file mode 100644 index 000000000..33ddf62af --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/FilterList.tsx @@ -0,0 +1 @@ +export default function FilterList() {} diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx new file mode 100644 index 000000000..2ec06a245 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -0,0 +1,91 @@ +import {Stack} from "@mantine/core" +import {IconChevronDown} from "@tabler/icons-react" +import React, {useState} from "react" +import {DropdownConfig} from "../types" +import OperationFilter from "./OperationFilter" +import TableNameFilter from "./TableNameFilter" +import TimestampFilter from "./TimestampFilter" +import UserFilter from "./UserFilter" + +interface Args { + filters: DropdownConfig[] + className?: string +} + +export default function FiltersCollapse({filters, className}: Args) { + const [expanded, setExpanded] = useState(true) + + const toggleExpanded = () => { + setExpanded(!expanded) + } + + let filterComponents: React.ReactElement[] = [] + + if (filters.length == 0) { + return <> + } + + filters.forEach(f => { + if (f.visible) { + switch (f.key) { + case "timestamp": + filterComponents.push() + break + case "operation": + filterComponents.push() + break + case "table_name": + filterComponents.push() + break + case "user": + filterComponents.push() + break + } + } + }) + + return ( +
+
+ +
+ + {expanded && {filterComponents}} +
+ ) +} + +const styles: {[key: string]: React.CSSProperties} = { + container: { + width: "100%", + border: "1px solid #e0e0e0", + backgroundColor: "#ffffff", + overflow: "hidden" + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "4px 4px", + backgroundColor: "rgba(0, 0, 0, 0.03)", + cursor: "pointer", + borderBottom: "1px solid #e0e0e0", + transition: "background-color 0.2s ease" + }, + icon: { + transition: "transform 0.2s ease-in-out", + color: "#666666" + }, + content: { + overflow: "hidden", + transition: + "max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out", + backgroundColor: "#ffffff" + } +} diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index d2cc22cad..239c3b31a 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,20 +1,24 @@ import {Container, Group, Stack} from "@mantine/core" import type {SortState} from "kommon" -import {useCallback} from "react" +import {useCallback, useState} from "react" import type {AuditLogItem, DropdownConfig, SortBy} from "../types" import auditLogColumns from "./auditLogColumns" import FilterSelector from "./DropdownSelector" import useAuditLogTable from "./useAuditLogTable" import {ColumnSelector, DataTable, TablePagination, useTableData} from "kommon" +import Filters from "./FiltersCollapse" -let filtersConfig = [ +let initialFilters = [ {key: "timestamp", label: "Timestamp", visible: false}, + {key: "operation", label: "Operation", visible: false}, + {key: "table_name", label: "Table", visible: false}, {key: "user", label: "User", visible: false} ] export default function AuditLogsList() { const auditLogTable = useAuditLogTable() + const [filtersList, setFiltersList] = useState([]) // Table state management const {state, actions, visibleColumns} = useTableData({ @@ -39,7 +43,8 @@ export default function AuditLogsList() { ) const onFilterVisibilityChange = (items: DropdownConfig[]) => { - console.log(items) + const visibleItems = items.filter(i => i.visible) + setFiltersList(visibleItems) } if (auditLogTable.isError) { @@ -54,49 +59,48 @@ export default function AuditLogsList() { } return ( - - - - - - - - - + + + - - - +
+ + + + +
) } diff --git a/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx b/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx new file mode 100644 index 000000000..ccaca24ce --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx @@ -0,0 +1,14 @@ +import {MultiSelect, Paper} from "@mantine/core" + +export default function OperationFilter() { + return ( + + + + ) +} diff --git a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx new file mode 100644 index 000000000..02cdf0ddd --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx @@ -0,0 +1,22 @@ +import {MultiSelect, Paper} from "@mantine/core" + +export default function TableNameFilter() { + return ( + + + + ) +} diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx new file mode 100644 index 000000000..3b3fabb96 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx @@ -0,0 +1,92 @@ +import {Button, Group, Paper, Stack} from "@mantine/core" +import {DateTimePicker} from "@mantine/dates" +import React, {useState} from "react" + +type TimestampMode = "range" | "older" | "newer" + +interface TimestampRange { + from?: Date | null + to?: Date | null +} + +interface TimestampPickerProps { + value?: { + mode: TimestampMode + range: TimestampRange + } + onChange?: (value: {mode: TimestampMode; range: TimestampRange}) => void +} + +const TimestampPicker: React.FC = ({value, onChange}) => { + const [range, setRange] = useState( + value?.range || {from: null, to: null} + ) + + const handleQuickSelect = (type: "today" | "1hour" | "3hours") => { + const now = new Date() + let newRange: TimestampRange = {from: null, to: null} + + switch (type) { + case "today": + const startOfDay = new Date(now) + startOfDay.setHours(0, 0, 0, 0) + const endOfDay = new Date(now) + endOfDay.setHours(23, 59, 59, 999) + newRange = {from: startOfDay, to: endOfDay} + break + case "1hour": + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) + newRange = {from: oneHourAgo, to: now} + break + case "3hours": + const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000) + newRange = {from: threeHoursAgo, to: now} + break + } + + setRange(newRange) + onChange?.({mode: "range", range: newRange}) + } + + return ( + + + + + + + + + + + + + + + ) +} + +export default TimestampPicker diff --git a/frontend/apps/ui/src/features/audit/components/UserFilter.tsx b/frontend/apps/ui/src/features/audit/components/UserFilter.tsx new file mode 100644 index 000000000..8e08ea632 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/UserFilter.tsx @@ -0,0 +1,3 @@ +export default function UserFilter() { + return <> +} From 300241e4cb83595cba5e52a717a145ac95384808 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Aug 2025 18:03:41 +0200 Subject: [PATCH 11/40] fix theme dark/light mode change --- .../audit/components/FiltersCollapse.tsx | 68 ++++++++----------- 1 file changed, 28 insertions(+), 40 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx index 2ec06a245..bbbd444fb 100644 --- a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -1,4 +1,4 @@ -import {Stack} from "@mantine/core" +import {Box, Paper, Stack, UnstyledButton} from "@mantine/core" import {IconChevronDown} from "@tabler/icons-react" import React, {useState} from "react" import {DropdownConfig} from "../types" @@ -45,47 +45,35 @@ export default function FiltersCollapse({filters, className}: Args) { }) return ( -
-
- + + -
+ > + + + - {expanded && {filterComponents}} -
+ {expanded && ( + + {filterComponents} + + )} + ) } - -const styles: {[key: string]: React.CSSProperties} = { - container: { - width: "100%", - border: "1px solid #e0e0e0", - backgroundColor: "#ffffff", - overflow: "hidden" - }, - header: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: "4px 4px", - backgroundColor: "rgba(0, 0, 0, 0.03)", - cursor: "pointer", - borderBottom: "1px solid #e0e0e0", - transition: "background-color 0.2s ease" - }, - icon: { - transition: "transform 0.2s ease-in-out", - color: "#666666" - }, - content: { - overflow: "hidden", - transition: - "max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out", - backgroundColor: "#ffffff" - } -} From 9e97095b9e8947d3b9df8920abcff85969cb8f7e Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Aug 2025 18:35:04 +0200 Subject: [PATCH 12/40] OKish UI --- .../apps/ui/src/components/Header/Header.tsx | 2 +- .../src/components/Header/SidebarToggle.tsx | 16 +++++++++++- .../audit/components/FiltersCollapse.tsx | 26 ++++++++++++++++--- .../features/audit/components/UserFilter.tsx | 16 ++++++++++-- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/frontend/apps/ui/src/components/Header/Header.tsx b/frontend/apps/ui/src/components/Header/Header.tsx index b0f5f564f..6f4788f47 100644 --- a/frontend/apps/ui/src/components/Header/Header.tsx +++ b/frontend/apps/ui/src/components/Header/Header.tsx @@ -4,10 +4,10 @@ import logoURL from "/logo_transparent_bg.svg" import {ColorSchemeToggle} from "@/components/ColorSchemeToggle/ColorSchemeToggle" import classes from "./Header.module.css" +import LanguageMenu from "./LanguageMenu" import Search from "./Search" import SidebarToggle from "./SidebarToggle" import UserMenu from "./UserMenu" -import LanguageMenu from "./LanguageMenu" function Header() { const theme = useMantineTheme() diff --git a/frontend/apps/ui/src/components/Header/SidebarToggle.tsx b/frontend/apps/ui/src/components/Header/SidebarToggle.tsx index b2cdae682..f3037c2b1 100644 --- a/frontend/apps/ui/src/components/Header/SidebarToggle.tsx +++ b/frontend/apps/ui/src/components/Header/SidebarToggle.tsx @@ -11,7 +11,21 @@ export default function SidebarToggle() { dispatch(toggleNavBar()) } return ( - onClick()}> + onClick()} + style={{ + outline: "none", + boxShadow: "none", + "&:focus": { + outline: "none", + boxShadow: "none" + }, + "&:active": { + outline: "none", + boxShadow: "none" + } + }} + > ) diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx index bbbd444fb..0ea445452 100644 --- a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -1,4 +1,4 @@ -import {Box, Paper, Stack, UnstyledButton} from "@mantine/core" +import {Box, Group, Paper, UnstyledButton} from "@mantine/core" import {IconChevronDown} from "@tabler/icons-react" import React, {useState} from "react" import {DropdownConfig} from "../types" @@ -48,10 +48,26 @@ export default function FiltersCollapse({filters, className}: Args) { - + - {filterComponents} + + {filterComponents} + )} diff --git a/frontend/apps/ui/src/features/audit/components/UserFilter.tsx b/frontend/apps/ui/src/features/audit/components/UserFilter.tsx index 8e08ea632..ae921aab1 100644 --- a/frontend/apps/ui/src/features/audit/components/UserFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/UserFilter.tsx @@ -1,3 +1,15 @@ -export default function UserFilter() { - return <> +import {MultiSelect, Paper} from "@mantine/core" + +export default function UsersListFilter() { + return ( + + + + ) } From f1b395ea588895942a9589a1db52f3498fa6dcf0 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Aug 2025 18:46:42 +0200 Subject: [PATCH 13/40] OKish UI --- frontend/apps/ui/src/features/audit/components/UserFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/apps/ui/src/features/audit/components/UserFilter.tsx b/frontend/apps/ui/src/features/audit/components/UserFilter.tsx index ae921aab1..f8be42f60 100644 --- a/frontend/apps/ui/src/features/audit/components/UserFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/UserFilter.tsx @@ -5,7 +5,7 @@ export default function UsersListFilter() { Date: Sat, 23 Aug 2025 18:50:43 +0200 Subject: [PATCH 14/40] rebase and minor adjustment in package.json --- frontend/apps/ui/package.json | 2 +- frontend/yarn.lock | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/apps/ui/package.json b/frontend/apps/ui/package.json index 628607baa..3842e1552 100644 --- a/frontend/apps/ui/package.json +++ b/frontend/apps/ui/package.json @@ -34,7 +34,7 @@ "js-cookie": "^3.0.5", "kommon": "workspace:*", "pdfjs-dist": "^5.3.93", - "react": "^19.1.0", + "react": "^19.1.1", "react-dom": "^19.1.1", "react-i18next": "^15.7.1", "react-redux": "^9.2.0", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 61da25a9d..9eb6e6cdb 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3946,6 +3946,13 @@ __metadata: languageName: node linkType: hard +"react@npm:^19.1.1": + version: 19.1.1 + resolution: "react@npm:19.1.1" + checksum: 10c0/8c9769a2dfd02e603af6445058325e6c8a24b47b185d0e461f66a6454765ddcaecb3f0a90184836c68bb509f3c38248359edbc42f0d07c23eb500a5c30c87b4e + languageName: node + linkType: hard + "readdirp@npm:^4.0.1": version: 4.1.2 resolution: "readdirp@npm:4.1.2" @@ -4427,7 +4434,7 @@ __metadata: kommon: "workspace:*" pdfjs-dist: "npm:^5.3.93" prettier: "npm:^3.6.2" - react: "npm:^19.1.0" + react: "npm:^19.1.1" react-dom: "npm:^19.1.1" react-i18next: "npm:^15.7.1" react-redux: "npm:^9.2.0" From 2566c09b0e1b9e5de1ddce0c9c179ec5e3a7f4dc Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sun, 24 Aug 2025 07:12:25 +0200 Subject: [PATCH 15/40] filter works for operation and table_name filters --- .../apps/ui/src/features/audit/apiSlice.ts | 2 +- .../audit/components/FiltersCollapse.tsx | 20 ++++++-- .../ui/src/features/audit/components/List.tsx | 5 +- .../audit/components/OperationFilter.tsx | 16 ++++++- .../audit/components/TableNameFilter.tsx | 16 ++++++- .../audit/components/TimestampFilter.tsx | 3 ++ .../audit/components/useAuditLogTable.ts | 21 +------- frontend/apps/ui/src/features/audit/types.ts | 21 ++++++++ papermerge/core/features/audit/db/api.py | 14 +----- papermerge/core/features/audit/router.py | 1 - papermerge/core/features/audit/schema.py | 48 +++++++++---------- 11 files changed, 101 insertions(+), 66 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/apiSlice.ts b/frontend/apps/ui/src/features/audit/apiSlice.ts index c5c117ea9..8d4c8c9e7 100644 --- a/frontend/apps/ui/src/features/audit/apiSlice.ts +++ b/frontend/apps/ui/src/features/audit/apiSlice.ts @@ -21,7 +21,7 @@ export interface AuditLogQueryParams extends Partial { sort_direction?: "asc" | "desc" // Filters - filter_operation?: "INSERT" | "UPDATE" | "DELETE" + filter_operation?: string filter_table_name?: string filter_username?: string filter_user_id?: string diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx index 0ea445452..ec667106e 100644 --- a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -1,6 +1,7 @@ import {Box, Group, Paper, UnstyledButton} from "@mantine/core" import {IconChevronDown} from "@tabler/icons-react" import React, {useState} from "react" +import type {AuditLogQueryParams} from "../types" import {DropdownConfig} from "../types" import OperationFilter from "./OperationFilter" import TableNameFilter from "./TableNameFilter" @@ -10,9 +11,14 @@ import UserFilter from "./UserFilter" interface Args { filters: DropdownConfig[] className?: string + setQueryParams: React.Dispatch> } -export default function FiltersCollapse({filters, className}: Args) { +export default function FiltersCollapse({ + filters, + className, + setQueryParams +}: Args) { const [expanded, setExpanded] = useState(true) const toggleExpanded = () => { @@ -29,13 +35,19 @@ export default function FiltersCollapse({filters, className}: Args) { if (f.visible) { switch (f.key) { case "timestamp": - filterComponents.push() + filterComponents.push( + + ) break case "operation": - filterComponents.push() + filterComponents.push( + + ) break case "table_name": - filterComponents.push() + filterComponents.push( + + ) break case "user": filterComponents.push() diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 239c3b31a..20fb623c5 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -60,7 +60,10 @@ export default function AuditLogsList() { return ( - + > +} + +export default function OperationFilter({setQueryParams}: Args) { + const onChange = (values: string[]) => { + setQueryParams(prev => ({ + ...prev, + filter_operation: values.join(",") + })) + } -export default function OperationFilter() { return ( diff --git a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx index 02cdf0ddd..d35fcb7ad 100644 --- a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx @@ -1,6 +1,19 @@ import {MultiSelect, Paper} from "@mantine/core" +import React from "react" +import type {AuditLogQueryParams} from "../types" + +interface Args { + setQueryParams: React.Dispatch> +} + +export default function TableNameFilter({setQueryParams}: Args) { + const onChange = (values: string[]) => { + setQueryParams(prev => ({ + ...prev, + filter_table_name: values.join(",") + })) + } -export default function TableNameFilter() { return ( > onChange?: (value: {mode: TimestampMode; range: TimestampRange}) => void } diff --git a/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts index 789e382f9..1e8909299 100644 --- a/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts +++ b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts @@ -1,7 +1,7 @@ -import type {PaginatedArgs} from "@/types" import type {FilterValue} from "kommon" import {useCallback, useMemo, useState} from "react" import {useGetPaginatedAuditLogsQuery} from "../apiSlice" +import type {AuditLogQueryParams} from "../types" type SortBy = | "timestamp" @@ -12,25 +12,6 @@ type SortBy = | "user_id" | "id" -export interface AuditLogQueryParams extends Partial { - // Pagination (inherited from PaginatedArgs) - page_number?: number - page_size?: number - - // Sorting - sort_by?: SortBy - sort_direction?: "asc" | "desc" - - // Filters - filter_operation?: "INSERT" | "UPDATE" | "DELETE" - filter_table_name?: string - filter_username?: string - filter_user_id?: string - filter_record_id?: string - filter_timestamp_from?: string // ISO string format - filter_timestamp_to?: string // ISO string format -} - // Enhanced helper hook with filter support export default function useAuditLogTable() { const [queryParams, setQueryParams] = useState({ diff --git a/frontend/apps/ui/src/features/audit/types.ts b/frontend/apps/ui/src/features/audit/types.ts index fc15bac84..91b1e0754 100644 --- a/frontend/apps/ui/src/features/audit/types.ts +++ b/frontend/apps/ui/src/features/audit/types.ts @@ -1,3 +1,5 @@ +import type {PaginatedArgs} from "@/types" + export type AuditOperation = "INSERT" | "UPDATE" | "DELETE" | "TRUNCATE" export type AuditLog = { @@ -34,3 +36,22 @@ export interface DropdownConfig { label: string visible?: boolean } + +export interface AuditLogQueryParams extends Partial { + // Pagination (inherited from PaginatedArgs) + page_number?: number + page_size?: number + + // Sorting + sort_by?: SortBy + sort_direction?: "asc" | "desc" + + // Filters + filter_operation?: string + filter_table_name?: string + filter_username?: string + filter_user_id?: string + filter_record_id?: string + filter_timestamp_from?: string // ISO string format + filter_timestamp_to?: string // ISO string format +} diff --git a/papermerge/core/features/audit/db/api.py b/papermerge/core/features/audit/db/api.py index 3dd477f17..db67c7ec4 100644 --- a/papermerge/core/features/audit/db/api.py +++ b/papermerge/core/features/audit/db/api.py @@ -38,17 +38,6 @@ async def get_audit_logs( sort_direction: Optional[str] = None, filters: Optional[Dict[str, Dict[str, Any]]] = None ) -> schema.PaginatedResponse[schema.AuditLog]: - """ - Advanced version that supports complex filtering with operators. - - Expected filters format: - { - "operation": {"value": "INSERT", "operator": "equals"}, - "table_name": {"value": "nodes", "operator": "contains"}, - "username": {"value": ["admin", "user1"], "operator": "in"} - } - """ - base_query = select(orm.AuditLog) count_query = select(func.count(orm.AuditLog.id)) @@ -85,6 +74,8 @@ async def get_audit_logs( elif operator == "in" and isinstance(value, list): filter_conditions.append(column_attr.in_(value)) + elif operator == "in" and isinstance(value, str): + filter_conditions.append(column_attr.in_(value.split(","))) # Date range handling for timestamp elif column == "timestamp" and operator == "range" and isinstance(value, dict): @@ -128,7 +119,6 @@ async def get_audit_logs( db_audit_logs = (await db_session.scalars(base_query)).all() items = [] for db_audit_log in db_audit_logs: - print(db_audit_log) item = schema.AuditLog.model_validate(db_audit_log) items.append(item) diff --git a/papermerge/core/features/audit/router.py b/papermerge/core/features/audit/router.py index ee799b9ac..57818652d 100644 --- a/papermerge/core/features/audit/router.py +++ b/papermerge/core/features/audit/router.py @@ -31,7 +31,6 @@ async def get_audit_logs( Required scope: `{scope}` """ - # Convert to advanced format advanced_filters = params.to_advanced_filters() # Use your advanced database function diff --git a/papermerge/core/features/audit/schema.py b/papermerge/core/features/audit/schema.py index b275958f3..4051380aa 100644 --- a/papermerge/core/features/audit/schema.py +++ b/papermerge/core/features/audit/schema.py @@ -3,7 +3,7 @@ from typing import Optional, Dict, Any, Literal from fastapi import Query -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator from .types import AuditOperation @@ -52,13 +52,13 @@ class AuditLogParams(BaseModel): ) # Filter parameters - individual query parameters - filter_operation: Optional[Literal["INSERT", "UPDATE", "DELETE"]] = Query( - None, - description="Filter by operation type: INSERT, UPDATE, or DELETE" + filter_operation: Optional[str] = Query( + default=None, + description="Comma-separated list of operations: INSERT,UPDATE,DELETE,TRUNCATE" ) filter_table_name: Optional[str] = Query( None, - description="Filter by table name (partial match, case-insensitive)" + description="Comma-serarater list of table names" ) filter_username: Optional[str] = Query( None, @@ -82,42 +82,40 @@ class AuditLogParams(BaseModel): None, description="Filter to timestamp (ISO format: 2025-08-21T06:35:10Z)" ) + @field_validator('filter_operation', mode='before') + @classmethod + def parse_operations(cls, v): + if v is None: + return None + if isinstance(v, str): + # Split by comma and validate each value + operations = [op.strip().upper() for op in v.split(',')] + valid_ops = {"INSERT", "UPDATE", "DELETE", "TRUNCATE"} + for op in operations: + if op not in valid_ops: + raise ValueError(f"Invalid operation: {op}") + return v + return v def to_advanced_filters(self) -> Optional[Dict[str, Dict[str, Any]]]: - """ - Convert simple filter parameters to advanced filter format - for use with get_audit_logs_advanced function. - """ filters = {} if self.filter_operation: filters["operation"] = { - "value": self.filter_operation, - "operator": "equals" + "value": self.filter_operation.split(","), + "operator": "in" } if self.filter_table_name: filters["table_name"] = { "value": self.filter_table_name, - "operator": "contains" + "operator": "in" } if self.filter_username: filters["username"] = { "value": self.filter_username, - "operator": "contains" - } - - if self.filter_user_id: - filters["user_id"] = { - "value": self.filter_user_id, - "operator": "equals" - } - - if self.filter_record_id: - filters["record_id"] = { - "value": self.filter_record_id, - "operator": "contains" + "operator": "in" } # Handle timestamp range filtering From f55c2c928728acfb22bfd35c1fb1da564899f876 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sun, 24 Aug 2025 07:54:41 +0200 Subject: [PATCH 16/40] It works! --- .../audit/components/TimestampFilter.tsx | 66 ++++++++++++++----- .../audit/components/useAuditLogTable.ts | 2 +- papermerge/core/features/audit/schema.py | 7 +- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx index b23ffd382..5b4da145c 100644 --- a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx @@ -2,9 +2,7 @@ import {Button, Group, Paper, Stack} from "@mantine/core" import {DateTimePicker} from "@mantine/dates" import type {AuditLogQueryParams} from "../types" -import React, {useState} from "react" - -type TimestampMode = "range" | "older" | "newer" +import React, {useEffect, useState} from "react" interface TimestampRange { from?: Date | null @@ -12,18 +10,50 @@ interface TimestampRange { } interface TimestampPickerProps { - value?: { - mode: TimestampMode - range: TimestampRange - } setQueryParams: React.Dispatch> - onChange?: (value: {mode: TimestampMode; range: TimestampRange}) => void } -const TimestampPicker: React.FC = ({value, onChange}) => { - const [range, setRange] = useState( - value?.range || {from: null, to: null} - ) +const TimestampPicker: React.FC = ({setQueryParams}) => { + const [range, setRange] = useState() + + const onChangeFrom = (value: string | null) => { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_from: value ? value : undefined + })) + + setRange(prev => ({ + ...prev, + from: value ? new Date(value) : null + })) + } + + const onChangeTo = (value: string | null) => { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_from: value ? value : undefined + })) + setRange(prev => ({ + ...prev, + to: value ? new Date(value) : null + })) + } + + useEffect(() => { + if (range?.from) { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_from: range?.from?.toISOString() + })) + } + + if (range?.to) { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_to: range?.to?.toISOString() + })) + } + }, [range]) const handleQuickSelect = (type: "today" | "1hour" | "3hours") => { const now = new Date() @@ -48,7 +78,6 @@ const TimestampPicker: React.FC = ({value, onChange}) => { } setRange(newRange) - onChange?.({mode: "range", range: newRange}) } return ( @@ -57,11 +86,18 @@ const TimestampPicker: React.FC = ({value, onChange}) => { + - diff --git a/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts index 1e8909299..b277120ce 100644 --- a/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts +++ b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts @@ -38,7 +38,7 @@ export default function useAuditLogTable() { filters.push({ column: "table_name", value: queryParams.filter_table_name, - operator: "contains" + operator: "equals" }) } diff --git a/papermerge/core/features/audit/schema.py b/papermerge/core/features/audit/schema.py index 4051380aa..77703971f 100644 --- a/papermerge/core/features/audit/schema.py +++ b/papermerge/core/features/audit/schema.py @@ -58,7 +58,7 @@ class AuditLogParams(BaseModel): ) filter_table_name: Optional[str] = Query( None, - description="Comma-serarater list of table names" + description="Comma-separated list of table names (e.g. users, groups, nodes)" ) filter_username: Optional[str] = Query( None, @@ -76,12 +76,13 @@ class AuditLogParams(BaseModel): # Date range filter parameters filter_timestamp_from: Optional[str] = Query( None, - description="Filter from timestamp (ISO format: 2025-08-20T06:35:10Z)" + description="Filter from timestamp (ISO 8601 format: 2025-08-20T06:35:10Z)" ) filter_timestamp_to: Optional[str] = Query( None, - description="Filter to timestamp (ISO format: 2025-08-21T06:35:10Z)" + description="Filter to timestamp (ISO 8601 format: 2025-08-21T06:35:10Z)" ) + @field_validator('filter_operation', mode='before') @classmethod def parse_operations(cls, v): From 13bf409d9a00d7f3541f57cff4e7965e1aca52f7 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sun, 24 Aug 2025 08:01:11 +0200 Subject: [PATCH 17/40] minor adjustments --- .../apps/ui/src/features/audit/components/TimestampFilter.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx index 5b4da145c..36f4e36f0 100644 --- a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx @@ -87,12 +87,14 @@ const TimestampPicker: React.FC = ({setQueryParams}) => { Date: Sun, 24 Aug 2025 08:07:19 +0200 Subject: [PATCH 18/40] sorted table names --- .../audit/components/TableNameFilter.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx index d35fcb7ad..db05aaefd 100644 --- a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx @@ -23,13 +23,20 @@ export default function TableNameFilter({setQueryParams}: Args) { clearable onChange={onChange} data={[ + "nodes", + "document_versions", + "custom_fields", + "document_types", + "shared_nodes", + "tags", + "users", + "groups", + "users_roles", "users_groups", "roles_permissions", - "documents", - "document_types_custom_fields", - "nodes", - "users" - ]} + "nodes_tags", + "document_types_custom_fields" + ].sort()} /> ) From e0011a36085844043d5e7ce345cb696b2b4548c1 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sun, 24 Aug 2025 08:39:12 +0200 Subject: [PATCH 19/40] add useDynamicHeight --- .../audit/components/FiltersCollapse.tsx | 163 +++++++++--------- .../ui/src/features/audit/components/List.tsx | 47 +++-- .../audit/components/auditLogColumns.tsx | 2 +- .../DocumentsByTypeCommander.tsx | 2 +- .../nodes => }/hooks/useDynamicHeight.ts | 0 .../src/components/Table/TablePagination.tsx | 112 ++++++------ 6 files changed, 174 insertions(+), 152 deletions(-) rename frontend/apps/ui/src/{features/nodes => }/hooks/useDynamicHeight.ts (100%) diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx index ec667106e..e48f1c40f 100644 --- a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -1,6 +1,6 @@ import {Box, Group, Paper, UnstyledButton} from "@mantine/core" import {IconChevronDown} from "@tabler/icons-react" -import React, {useState} from "react" +import React, {forwardRef, useState} from "react" import type {AuditLogQueryParams} from "../types" import {DropdownConfig} from "../types" import OperationFilter from "./OperationFilter" @@ -14,96 +14,97 @@ interface Args { setQueryParams: React.Dispatch> } -export default function FiltersCollapse({ - filters, - className, - setQueryParams -}: Args) { - const [expanded, setExpanded] = useState(true) +const FiltersCollapse = forwardRef( + ({filters, className, setQueryParams}, ref) => { + const [expanded, setExpanded] = useState(true) - const toggleExpanded = () => { - setExpanded(!expanded) - } + const toggleExpanded = () => { + setExpanded(!expanded) + } - let filterComponents: React.ReactElement[] = [] + let filterComponents: React.ReactElement[] = [] - if (filters.length == 0) { - return <> - } + if (filters.length == 0) { + return <> + } - filters.forEach(f => { - if (f.visible) { - switch (f.key) { - case "timestamp": - filterComponents.push( - - ) - break - case "operation": - filterComponents.push( - - ) - break - case "table_name": - filterComponents.push( - - ) - break - case "user": - filterComponents.push() - break + filters.forEach(f => { + if (f.visible) { + switch (f.key) { + case "timestamp": + filterComponents.push( + + ) + break + case "operation": + filterComponents.push( + + ) + break + case "table_name": + filterComponents.push( + + ) + break + case "user": + filterComponents.push() + break + } } - } - }) + }) - return ( - - - - - - + > + + + - {expanded && ( - - - {filterComponents} - - - )} - - ) -} + {expanded && ( + + + {filterComponents} + + + )} + + ) + } +) + +export default FiltersCollapse diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 20fb623c5..30d62775a 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,6 +1,7 @@ -import {Container, Group, Stack} from "@mantine/core" +import {useDynamicHeight} from "@/hooks/useDynamicHeight" +import {Container, Group, ScrollArea, Stack} from "@mantine/core" import type {SortState} from "kommon" -import {useCallback, useState} from "react" +import {useCallback, useRef, useState} from "react" import type {AuditLogItem, DropdownConfig, SortBy} from "../types" import auditLogColumns from "./auditLogColumns" import FilterSelector from "./DropdownSelector" @@ -19,6 +20,9 @@ let initialFilters = [ export default function AuditLogsList() { const auditLogTable = useAuditLogTable() const [filtersList, setFiltersList] = useState([]) + const actionButtonsRef = useRef(null) + const filtersRef = useRef(null) + const paginationRef = useRef(null) // Pagination // Table state management const {state, actions, visibleColumns} = useTableData({ @@ -31,6 +35,12 @@ export default function AuditLogsList() { initialColumns: auditLogColumns }) + const remainingHeight = useDynamicHeight([ + actionButtonsRef, + filtersRef, + paginationRef + ]) + // Handle sorting changes const handleSortChange = useCallback( (newSorting: SortState) => { @@ -61,10 +71,11 @@ export default function AuditLogsList() { return ( - + - - + + + [] = [ label: "Table", sortable: true, filterable: true, - width: 150, + width: 100, render: value => ( diff --git a/frontend/apps/ui/src/features/nodes/components/Commander/DocumentsByTypeCommander/DocumentsByTypeCommander.tsx b/frontend/apps/ui/src/features/nodes/components/Commander/DocumentsByTypeCommander/DocumentsByTypeCommander.tsx index d85e8f014..5a4608f73 100644 --- a/frontend/apps/ui/src/features/nodes/components/Commander/DocumentsByTypeCommander/DocumentsByTypeCommander.tsx +++ b/frontend/apps/ui/src/features/nodes/components/Commander/DocumentsByTypeCommander/DocumentsByTypeCommander.tsx @@ -2,7 +2,6 @@ import {useAppDispatch, useAppSelector} from "@/app/hooks" import Pagination from "@/components/Pagination" import PanelContext from "@/contexts/PanelContext" import {useGetDocsByTypeQuery} from "@/features/document/store/apiSlice" -import {useDynamicHeight} from "@/features/nodes/hooks/useDynamicHeight" import { commanderLastPageSizeUpdated, documentsByTypeCommanderColumnsUpdated, @@ -10,6 +9,7 @@ import { selectDocumentsByTypeCommanderVisibleColumns, selectLastPageSize } from "@/features/ui/uiSlice" +import {useDynamicHeight} from "@/hooks/useDynamicHeight" import type {PanelMode} from "@/types" import { Box, diff --git a/frontend/apps/ui/src/features/nodes/hooks/useDynamicHeight.ts b/frontend/apps/ui/src/hooks/useDynamicHeight.ts similarity index 100% rename from frontend/apps/ui/src/features/nodes/hooks/useDynamicHeight.ts rename to frontend/apps/ui/src/hooks/useDynamicHeight.ts diff --git a/frontend/packages/kommon/src/components/Table/TablePagination.tsx b/frontend/packages/kommon/src/components/Table/TablePagination.tsx index 8f563a56c..8fd1deb0b 100644 --- a/frontend/packages/kommon/src/components/Table/TablePagination.tsx +++ b/frontend/packages/kommon/src/components/Table/TablePagination.tsx @@ -1,7 +1,8 @@ // components/TablePagination/TablePagination.tsx import {Group, Pagination, Select, Text} from "@mantine/core" +import {forwardRef} from "react" -interface TablePaginationProps { +interface TablePaginationArgs { currentPage: number totalPages: number pageSize: number @@ -12,58 +13,65 @@ interface TablePaginationProps { totalItems?: number } -export default function TablePagination({ - currentPage, - totalPages, - pageSize, - onPageChange, - onPageSizeChange, - pageSizeOptions = [10, 15, 25, 50, 100], - showPageSizeSelector = true, - totalItems -}: TablePaginationProps) { - const startItem = totalPages > 0 ? (currentPage - 1) * pageSize + 1 : 0 - const endItem = Math.min( - currentPage * pageSize, - totalItems || currentPage * pageSize - ) +const TablePagination = forwardRef( + ( + { + currentPage, + totalPages, + pageSize, + onPageChange, + onPageSizeChange, + pageSizeOptions = [10, 15, 25, 50, 100], + showPageSizeSelector = true, + totalItems + }, + ref + ) => { + const startItem = totalPages > 0 ? (currentPage - 1) * pageSize + 1 : 0 + const endItem = Math.min( + currentPage * pageSize, + totalItems || currentPage * pageSize + ) - return ( - - - {showPageSizeSelector && ( - - Show - ({ + value: String(size), + label: String(size) + }))} + value={String(pageSize)} + onChange={value => value && onPageSizeChange(Number(value))} + w={70} + /> + entries + + )} - {totalItems && ( - - Showing {startItem} to {endItem} of {totalItems} entries - - )} + {totalItems && ( + + Showing {startItem} to {endItem} of {totalItems} entries + + )} + + + + ) + } +) - - - ) -} +export default TablePagination From 8cb19d15abe2237b9e20fa7a056b56fecb67b34c Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sun, 24 Aug 2025 08:57:23 +0200 Subject: [PATCH 20/40] Store global UI state --- frontend/apps/ui/src/features/ui/uiSlice.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frontend/apps/ui/src/features/ui/uiSlice.ts b/frontend/apps/ui/src/features/ui/uiSlice.ts index 69be21d29..c1118caea 100644 --- a/frontend/apps/ui/src/features/ui/uiSlice.ts +++ b/frontend/apps/ui/src/features/ui/uiSlice.ts @@ -26,6 +26,7 @@ import type { SortMenuDirection } from "@/types" +import type {AuditOperation} from "@/features/audit/types" import type {CategoryColumn} from "@/features/nodes/components/Commander/DocumentsByTypeCommander/types" import {DialogVisiblity} from "@/types.d/common" @@ -219,6 +220,8 @@ interface LastInboxArg { last_inbox: LastInbox } +type AuditLogFilterKey = "timestamp" | "operation" | "table_name" + export interface UIState { uploader: UploaderState navbar: NavBarState @@ -281,6 +284,20 @@ export interface UIState { /* current page (number) in secondary viewer */ secondaryViewerCurrentPageNumber?: number viewerPageHaveChangedDialogVisibility?: DialogVisiblity + mainAuditLogSelectedFilters?: Array + mainAuditLogTimestampFilterValue?: {from: Date | null; to: Date | null} + mainAuditLogOperationFilterValue?: Array + mainAuditLogTableNameFilterValue?: Array + mainAuditLogUsernameFilterValue?: Array + mainAuditLogPageNumber?: number + mainAuditLogPageSize?: number + secondaryAuditLogSelectedFilters?: Array + secondaryAuditLogTimestampFilterValue?: {from: Date | null; to: Date | null} + secondaryAuditLogOperationFilterValue?: Array + secondaryAuditLogTableNameFilterValue?: Array + secondaryAuditLogUsernameFilterValue?: Array + secondaryAuditLogPageNumber?: number + secondaryAuditLogPageSize?: number } const initialState: UIState = { From ff6cb9943b8b568a1f6cbd11d5cba88a17b4f2da Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sun, 24 Aug 2025 09:37:00 +0200 Subject: [PATCH 21/40] adding global state for the audit log UI --- .../audit/components/FiltersCollapse.tsx | 4 +-- .../ui/src/features/audit/components/List.tsx | 24 +++++++++----- .../src/features/audit/hooks/useFilterList.ts | 33 +++++++++++++++++++ frontend/apps/ui/src/features/audit/types.ts | 2 +- frontend/apps/ui/src/features/ui/uiSlice.ts | 32 +++++++++++++++--- 5 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 frontend/apps/ui/src/features/audit/hooks/useFilterList.ts diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx index e48f1c40f..1643495c8 100644 --- a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -2,14 +2,14 @@ import {Box, Group, Paper, UnstyledButton} from "@mantine/core" import {IconChevronDown} from "@tabler/icons-react" import React, {forwardRef, useState} from "react" import type {AuditLogQueryParams} from "../types" -import {DropdownConfig} from "../types" +import {FilterListConfig} from "../types" import OperationFilter from "./OperationFilter" import TableNameFilter from "./TableNameFilter" import TimestampFilter from "./TimestampFilter" import UserFilter from "./UserFilter" interface Args { - filters: DropdownConfig[] + filters: FilterListConfig[] className?: string setQueryParams: React.Dispatch> } diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 30d62775a..542194fee 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,16 +1,20 @@ +import {useAppDispatch} from "@/app/hooks" +import {auditLogVisibleFilterUpdated} from "@/features/ui/uiSlice" import {useDynamicHeight} from "@/hooks/useDynamicHeight" import {Container, Group, ScrollArea, Stack} from "@mantine/core" import type {SortState} from "kommon" -import {useCallback, useRef, useState} from "react" -import type {AuditLogItem, DropdownConfig, SortBy} from "../types" +import {useCallback, useRef} from "react" +import type {AuditLogItem, FilterListConfig, SortBy} from "../types" import auditLogColumns from "./auditLogColumns" import FilterSelector from "./DropdownSelector" import useAuditLogTable from "./useAuditLogTable" +import {usePanelMode} from "@/hooks" import {ColumnSelector, DataTable, TablePagination, useTableData} from "kommon" +import useFilterList from "../hooks/useFilterList" import Filters from "./FiltersCollapse" -let initialFilters = [ +let initialFilterConfig = [ {key: "timestamp", label: "Timestamp", visible: false}, {key: "operation", label: "Operation", visible: false}, {key: "table_name", label: "Table", visible: false}, @@ -19,7 +23,9 @@ let initialFilters = [ export default function AuditLogsList() { const auditLogTable = useAuditLogTable() - const [filtersList, setFiltersList] = useState([]) + const dispatch = useAppDispatch() + const filtersList = useFilterList() + const mode = usePanelMode() const actionButtonsRef = useRef(null) const filtersRef = useRef(null) const paginationRef = useRef(null) // Pagination @@ -52,9 +58,11 @@ export default function AuditLogsList() { [auditLogTable] ) - const onFilterVisibilityChange = (items: DropdownConfig[]) => { - const visibleItems = items.filter(i => i.visible) - setFiltersList(visibleItems) + const onFilterVisibilityChange = (items: FilterListConfig[]) => { + const visibleFilterKeys = items.filter(i => i.visible).map(i => i.key) + dispatch( + auditLogVisibleFilterUpdated({filterKeys: visibleFilterKeys, mode}) + ) } if (auditLogTable.isError) { @@ -78,7 +86,7 @@ export default function AuditLogsList() { + selectAuditLogVisibleFilters(s, mode) + ) + const [filtersList, setFiltersList] = useState([]) + + useEffect(() => { + let newFilters: FilterListConfig[] = [] + allFiltersConfig.forEach(f => { + if (selectedFilterKeys && selectedFilterKeys?.includes(f.key)) { + newFilters.push({...f, visible: true}) + } + }) + + setFiltersList(newFilters) + }, [selectedFilterKeys]) + + return filtersList +} diff --git a/frontend/apps/ui/src/features/audit/types.ts b/frontend/apps/ui/src/features/audit/types.ts index 91b1e0754..8126c38bf 100644 --- a/frontend/apps/ui/src/features/audit/types.ts +++ b/frontend/apps/ui/src/features/audit/types.ts @@ -31,7 +31,7 @@ export type SortBy = | "user_id" | "id" -export interface DropdownConfig { +export interface FilterListConfig { key: string label: string visible?: boolean diff --git a/frontend/apps/ui/src/features/ui/uiSlice.ts b/frontend/apps/ui/src/features/ui/uiSlice.ts index c1118caea..2726d8d51 100644 --- a/frontend/apps/ui/src/features/ui/uiSlice.ts +++ b/frontend/apps/ui/src/features/ui/uiSlice.ts @@ -220,7 +220,7 @@ interface LastInboxArg { last_inbox: LastInbox } -type AuditLogFilterKey = "timestamp" | "operation" | "table_name" +type AuditLogFilterKey = "timestamp" | "operation" | "table_name" | "user" export interface UIState { uploader: UploaderState @@ -284,14 +284,14 @@ export interface UIState { /* current page (number) in secondary viewer */ secondaryViewerCurrentPageNumber?: number viewerPageHaveChangedDialogVisibility?: DialogVisiblity - mainAuditLogSelectedFilters?: Array + mainAuditLogVisibleFilters?: Array mainAuditLogTimestampFilterValue?: {from: Date | null; to: Date | null} mainAuditLogOperationFilterValue?: Array mainAuditLogTableNameFilterValue?: Array mainAuditLogUsernameFilterValue?: Array mainAuditLogPageNumber?: number mainAuditLogPageSize?: number - secondaryAuditLogSelectedFilters?: Array + secondaryAuditLogVisibleFilters?: Array secondaryAuditLogTimestampFilterValue?: {from: Date | null; to: Date | null} secondaryAuditLogOperationFilterValue?: Array secondaryAuditLogTableNameFilterValue?: Array @@ -920,6 +920,18 @@ const uiSlice = createSlice({ ) { const newVisibility = action.payload.visibility state.viewerPageHaveChangedDialogVisibility = newVisibility + }, + auditLogVisibleFilterUpdated( + state, + action: PayloadAction<{mode: PanelMode; filterKeys: Array}> + ) { + const {mode, filterKeys} = action.payload + if (mode == "main") { + state.mainAuditLogVisibleFilters = filterKeys + return + } + + state.secondaryAuditLogVisibleFilters = filterKeys } } }) @@ -971,7 +983,8 @@ export const { documentsByTypeCommanderColumnVisibilityToggled, lastHomeUpdated, lastInboxUpdated, - viewerPageHaveChangedDialogVisibilityChanged + viewerPageHaveChangedDialogVisibilityChanged, + auditLogVisibleFilterUpdated } = uiSlice.actions export default uiSlice.reducer @@ -1373,6 +1386,17 @@ export const selectViewerPagesHaveChangedDialogVisibility = ( return state.ui.viewerPageHaveChangedDialogVisibility || "closed" } +export const selectAuditLogVisibleFilters = ( + state: RootState, + mode: PanelMode +) => { + if (mode == "main") { + return state.ui.mainAuditLogVisibleFilters + } + + return state.ui.secondaryAuditLogVisibleFilters +} + /* Load initial collapse state value from cookie */ function initial_collapse_value(): boolean { const collapsed = Cookies.get(NAVBAR_COLLAPSED_COOKIE) as BooleanString From bc3dbfd34f463839e4a1e9acb8b755b9e8ecd937 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Mon, 25 Aug 2025 06:21:25 +0200 Subject: [PATCH 22/40] sync filter selector with global state --- .../audit/components/DropdownSelector.tsx | 61 ------------------- .../audit/components/FilterSelector.tsx | 55 +++++++++++++++++ .../ui/src/features/audit/components/List.tsx | 14 +---- .../src/features/audit/hooks/useFilterList.ts | 12 ++-- 4 files changed, 62 insertions(+), 80 deletions(-) delete mode 100644 frontend/apps/ui/src/features/audit/components/DropdownSelector.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/FilterSelector.tsx diff --git a/frontend/apps/ui/src/features/audit/components/DropdownSelector.tsx b/frontend/apps/ui/src/features/audit/components/DropdownSelector.tsx deleted file mode 100644 index bf257151b..000000000 --- a/frontend/apps/ui/src/features/audit/components/DropdownSelector.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import {ActionIcon, Checkbox, Popover, ScrollArea, Stack} from "@mantine/core" -import {IconFilter} from "@tabler/icons-react" -import {useState} from "react" -import type {DropdownConfig} from "../types" - -interface FilterSelectorArgs { - initialItems: DropdownConfig[] - onChange: (items: DropdownConfig[]) => void -} - -export default function DropdownSelector({ - initialItems, - onChange -}: FilterSelectorArgs) { - const [opened, setOpened] = useState(false) - const [items, setItems] = useState(initialItems) - - const onLocalChange = (key: DropdownConfig["key"], checked: boolean) => { - const newItems = items.map(i => - i.key === key ? {...i, visible: checked} : i - ) - - setItems(newItems) - onChange(newItems) - } - - return ( - - - setOpened(o => !o)}> - - - - - - - - {items.map(filter => ( - - onLocalChange(filter.key, e.currentTarget.checked) - } - size="sm" - /> - ))} - - - - - ) -} diff --git a/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx b/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx new file mode 100644 index 000000000..ed761a2e5 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx @@ -0,0 +1,55 @@ +import type {FilterListConfig} from "@/features/audit/types" +import {ActionIcon, Checkbox, Popover, ScrollArea, Stack} from "@mantine/core" +import {IconFilter} from "@tabler/icons-react" +import {useState} from "react" +import useFilterList from "../hooks/useFilterList" + +interface FilterSelectorArgs { + onChange: (items: FilterListConfig[]) => void +} + +export default function DropdownSelector({onChange}: FilterSelectorArgs) { + const [opened, setOpened] = useState(false) + const filtersList = useFilterList() + + const checkboxes = filtersList.map(filter => ( + onLocalChange(filter.key, e.currentTarget.checked)} + size="sm" + /> + )) + + const onLocalChange = (key: FilterListConfig["key"], checked: boolean) => { + const newItems = filtersList.map(i => + i.key === key ? {...i, visible: checked} : i + ) + + onChange(newItems) + } + + return ( + + + setOpened(o => !o)}> + + + + + + + {checkboxes} + + + + ) +} diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 542194fee..20b8bafab 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -6,7 +6,7 @@ import type {SortState} from "kommon" import {useCallback, useRef} from "react" import type {AuditLogItem, FilterListConfig, SortBy} from "../types" import auditLogColumns from "./auditLogColumns" -import FilterSelector from "./DropdownSelector" +import FilterSelector from "./FilterSelector" import useAuditLogTable from "./useAuditLogTable" import {usePanelMode} from "@/hooks" @@ -14,13 +14,6 @@ import {ColumnSelector, DataTable, TablePagination, useTableData} from "kommon" import useFilterList from "../hooks/useFilterList" import Filters from "./FiltersCollapse" -let initialFilterConfig = [ - {key: "timestamp", label: "Timestamp", visible: false}, - {key: "operation", label: "Operation", visible: false}, - {key: "table_name", label: "Table", visible: false}, - {key: "user", label: "User", visible: false} -] - export default function AuditLogsList() { const auditLogTable = useAuditLogTable() const dispatch = useAppDispatch() @@ -84,10 +77,7 @@ export default function AuditLogsList() { setQueryParams={auditLogTable.setQueryParams} /> - + selectAuditLogVisibleFilters(s, mode) ) - const [filtersList, setFiltersList] = useState([]) + const [filtersList, setFiltersList] = useState(FILTERS) useEffect(() => { - let newFilters: FilterListConfig[] = [] - allFiltersConfig.forEach(f => { - if (selectedFilterKeys && selectedFilterKeys?.includes(f.key)) { - newFilters.push({...f, visible: true}) - } + const newFilters = FILTERS.map(f => { + const visible = selectedFilterKeys && selectedFilterKeys.includes(f.key) + return {...f, visible} }) setFiltersList(newFilters) From ebdc47f7575850a7ae06c3f46409de64a952ab48 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Mon, 25 Aug 2025 08:01:01 +0200 Subject: [PATCH 23/40] save filters state into global redux store --- .../audit/components/FilterSelector.tsx | 1 - .../audit/components/FiltersCollapse.tsx | 26 ++++-- .../ui/src/features/audit/components/List.tsx | 10 +-- .../audit/components/TimestampFilter.tsx | 85 +++++++++++++++---- frontend/apps/ui/src/features/audit/types.ts | 5 ++ frontend/apps/ui/src/features/ui/uiSlice.ts | 60 ++++++++++++- 6 files changed, 149 insertions(+), 38 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx b/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx index ed761a2e5..5972f0626 100644 --- a/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx +++ b/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx @@ -26,7 +26,6 @@ export default function DropdownSelector({onChange}: FilterSelectorArgs) { const newItems = filtersList.map(i => i.key === key ? {...i, visible: checked} : i ) - onChange(newItems) } diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx index 1643495c8..45eaa5c1d 100644 --- a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -1,34 +1,44 @@ +import {useAppDispatch, useAppSelector} from "@/app/hooks" +import useFilterList from "@/features/audit/hooks/useFilterList" +import { + auditLogFiltersCollapseUpdated, + selectAuditLogFiltersCollapse +} from "@/features/ui/uiSlice" import {Box, Group, Paper, UnstyledButton} from "@mantine/core" import {IconChevronDown} from "@tabler/icons-react" -import React, {forwardRef, useState} from "react" +import React, {forwardRef} from "react" import type {AuditLogQueryParams} from "../types" -import {FilterListConfig} from "../types" + +import {usePanelMode} from "@/hooks" import OperationFilter from "./OperationFilter" import TableNameFilter from "./TableNameFilter" import TimestampFilter from "./TimestampFilter" import UserFilter from "./UserFilter" interface Args { - filters: FilterListConfig[] className?: string setQueryParams: React.Dispatch> } const FiltersCollapse = forwardRef( - ({filters, className, setQueryParams}, ref) => { - const [expanded, setExpanded] = useState(true) + ({className, setQueryParams}, ref) => { + const mode = usePanelMode() + const dispatch = useAppDispatch() + const filtersList = useFilterList() + const visibleFiltersOnly = filtersList.filter(f => f.visible) + const expanded = useAppSelector(s => selectAuditLogFiltersCollapse(s, mode)) const toggleExpanded = () => { - setExpanded(!expanded) + dispatch(auditLogFiltersCollapseUpdated({value: !expanded, mode})) } let filterComponents: React.ReactElement[] = [] - if (filters.length == 0) { + if (visibleFiltersOnly.length == 0) { return <> } - filters.forEach(f => { + visibleFiltersOnly.forEach(f => { if (f.visible) { switch (f.key) { case "timestamp": diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 20b8bafab..fa2b9759c 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -11,13 +11,12 @@ import useAuditLogTable from "./useAuditLogTable" import {usePanelMode} from "@/hooks" import {ColumnSelector, DataTable, TablePagination, useTableData} from "kommon" -import useFilterList from "../hooks/useFilterList" import Filters from "./FiltersCollapse" export default function AuditLogsList() { const auditLogTable = useAuditLogTable() const dispatch = useAppDispatch() - const filtersList = useFilterList() + const mode = usePanelMode() const actionButtonsRef = useRef(null) const filtersRef = useRef(null) @@ -53,6 +52,7 @@ export default function AuditLogsList() { const onFilterVisibilityChange = (items: FilterListConfig[]) => { const visibleFilterKeys = items.filter(i => i.visible).map(i => i.key) + dispatch( auditLogVisibleFilterUpdated({filterKeys: visibleFilterKeys, mode}) ) @@ -71,11 +71,7 @@ export default function AuditLogsList() { return ( - + diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx index 36f4e36f0..71f4d6cb3 100644 --- a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx @@ -1,8 +1,14 @@ import {Button, Group, Paper, Stack} from "@mantine/core" import {DateTimePicker} from "@mantine/dates" -import type {AuditLogQueryParams} from "../types" +import type {AuditLogQueryParams, TimestampFilterType} from "../types" -import React, {useEffect, useState} from "react" +import {useAppDispatch, useAppSelector} from "@/app/hooks" +import { + auditLogTimestampFilterValueUpdated, + selectAuditLogTimestampFilterValue +} from "@/features/ui/uiSlice" +import {usePanelMode} from "@/hooks" +import React, {useEffect} from "react" interface TimestampRange { from?: Date | null @@ -14,7 +20,9 @@ interface TimestampPickerProps { } const TimestampPicker: React.FC = ({setQueryParams}) => { - const [range, setRange] = useState() + const mode = usePanelMode() + const dispatch = useAppDispatch() + const range = useAppSelector(s => selectAuditLogTimestampFilterValue(s, mode)) const onChangeFrom = (value: string | null) => { setQueryParams(prev => ({ @@ -22,10 +30,17 @@ const TimestampPicker: React.FC = ({setQueryParams}) => { filter_timestamp_from: value ? value : undefined })) - setRange(prev => ({ - ...prev, - from: value ? new Date(value) : null - })) + const newFrom = value ? new Date(value) : null + + dispatch( + auditLogTimestampFilterValueUpdated({ + mode, + value: { + from: newFrom?.toDateString() || null, + to: range?.to || null + } + }) + ) } const onChangeTo = (value: string | null) => { @@ -33,31 +48,48 @@ const TimestampPicker: React.FC = ({setQueryParams}) => { ...prev, filter_timestamp_from: value ? value : undefined })) - setRange(prev => ({ - ...prev, - to: value ? new Date(value) : null - })) + const newTo = value ? new Date(value) : null + + dispatch( + auditLogTimestampFilterValueUpdated({ + mode, + value: { + to: newTo?.toISOString() || null, + from: range?.from || null + } + }) + ) } useEffect(() => { if (range?.from) { setQueryParams(prev => ({ ...prev, - filter_timestamp_from: range?.from?.toISOString() + filter_timestamp_from: range?.from || undefined + })) + } else { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_from: undefined })) } if (range?.to) { setQueryParams(prev => ({ ...prev, - filter_timestamp_to: range?.to?.toISOString() + filter_timestamp_to: range?.to || undefined + })) + } else { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_to: undefined })) } }, [range]) const handleQuickSelect = (type: "today" | "1hour" | "3hours") => { const now = new Date() - let newRange: TimestampRange = {from: null, to: null} + let newRange: TimestampFilterType = {from: null, to: null} switch (type) { case "today": @@ -65,19 +97,33 @@ const TimestampPicker: React.FC = ({setQueryParams}) => { startOfDay.setHours(0, 0, 0, 0) const endOfDay = new Date(now) endOfDay.setHours(23, 59, 59, 999) - newRange = {from: startOfDay, to: endOfDay} + newRange = {from: startOfDay.toISOString(), to: endOfDay.toISOString()} break case "1hour": const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) - newRange = {from: oneHourAgo, to: now} + newRange = {from: oneHourAgo.toISOString(), to: now.toISOString()} break case "3hours": const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000) - newRange = {from: threeHoursAgo, to: now} + newRange = {from: threeHoursAgo.toISOString(), to: now.toISOString()} break } + dispatch( + auditLogTimestampFilterValueUpdated({ + mode, + value: newRange + }) + ) + } - setRange(newRange) + const handleClear = () => { + const newRange: TimestampFilterType = {from: null, to: null} + dispatch( + auditLogTimestampFilterValueUpdated({ + mode, + value: newRange + }) + ) } return ( @@ -124,6 +170,9 @@ const TimestampPicker: React.FC = ({setQueryParams}) => { > Last 3 Hours + diff --git a/frontend/apps/ui/src/features/audit/types.ts b/frontend/apps/ui/src/features/audit/types.ts index 8126c38bf..d92389b24 100644 --- a/frontend/apps/ui/src/features/audit/types.ts +++ b/frontend/apps/ui/src/features/audit/types.ts @@ -55,3 +55,8 @@ export interface AuditLogQueryParams extends Partial { filter_timestamp_from?: string // ISO string format filter_timestamp_to?: string // ISO string format } + +export type TimestampFilterType = { + from: string | null // Date().toISOString() + to: string | null // Date().toISOString() +} diff --git a/frontend/apps/ui/src/features/ui/uiSlice.ts b/frontend/apps/ui/src/features/ui/uiSlice.ts index 2726d8d51..c3cf16e66 100644 --- a/frontend/apps/ui/src/features/ui/uiSlice.ts +++ b/frontend/apps/ui/src/features/ui/uiSlice.ts @@ -26,7 +26,7 @@ import type { SortMenuDirection } from "@/types" -import type {AuditOperation} from "@/features/audit/types" +import type {AuditOperation, TimestampFilterType} from "@/features/audit/types" import type {CategoryColumn} from "@/features/nodes/components/Commander/DocumentsByTypeCommander/types" import {DialogVisiblity} from "@/types.d/common" @@ -285,14 +285,16 @@ export interface UIState { secondaryViewerCurrentPageNumber?: number viewerPageHaveChangedDialogVisibility?: DialogVisiblity mainAuditLogVisibleFilters?: Array - mainAuditLogTimestampFilterValue?: {from: Date | null; to: Date | null} + mainAuditLogFiltersCollapse?: boolean + mainAuditLogTimestampFilterValue?: TimestampFilterType mainAuditLogOperationFilterValue?: Array mainAuditLogTableNameFilterValue?: Array mainAuditLogUsernameFilterValue?: Array mainAuditLogPageNumber?: number mainAuditLogPageSize?: number secondaryAuditLogVisibleFilters?: Array - secondaryAuditLogTimestampFilterValue?: {from: Date | null; to: Date | null} + secondaryAuditLogFiltersCollapse?: boolean + secondaryAuditLogTimestampFilterValue?: TimestampFilterType secondaryAuditLogOperationFilterValue?: Array secondaryAuditLogTableNameFilterValue?: Array secondaryAuditLogUsernameFilterValue?: Array @@ -932,6 +934,32 @@ const uiSlice = createSlice({ } state.secondaryAuditLogVisibleFilters = filterKeys + }, + + auditLogFiltersCollapseUpdated( + state, + action: PayloadAction<{mode: PanelMode; value: boolean}> + ) { + const {mode, value} = action.payload + if (mode == "main") { + state.mainAuditLogFiltersCollapse = value + return + } + + state.secondaryAuditLogFiltersCollapse = value + }, + + auditLogTimestampFilterValueUpdated( + state, + action: PayloadAction<{mode: PanelMode; value: TimestampFilterType}> + ) { + const {mode, value} = action.payload + if (mode == "main") { + state.mainAuditLogTimestampFilterValue = value + return + } + + state.secondaryAuditLogTimestampFilterValue = value } } }) @@ -984,7 +1012,9 @@ export const { lastHomeUpdated, lastInboxUpdated, viewerPageHaveChangedDialogVisibilityChanged, - auditLogVisibleFilterUpdated + auditLogVisibleFilterUpdated, + auditLogFiltersCollapseUpdated, + auditLogTimestampFilterValueUpdated } = uiSlice.actions export default uiSlice.reducer @@ -1397,6 +1427,28 @@ export const selectAuditLogVisibleFilters = ( return state.ui.secondaryAuditLogVisibleFilters } +export const selectAuditLogFiltersCollapse = ( + state: RootState, + mode: PanelMode +) => { + if (mode == "main") { + return state.ui.mainAuditLogFiltersCollapse + } + + return state.ui.secondaryAuditLogFiltersCollapse +} + +export const selectAuditLogTimestampFilterValue = ( + state: RootState, + mode: PanelMode +) => { + if (mode == "main") { + return state.ui.mainAuditLogTimestampFilterValue + } + + return state.ui.secondaryAuditLogTimestampFilterValue +} + /* Load initial collapse state value from cookie */ function initial_collapse_value(): boolean { const collapsed = Cookies.get(NAVBAR_COLLAPSED_COOKIE) as BooleanString From 595a3026051a7a38b78705453e52542fedaaee19 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Mon, 25 Aug 2025 08:14:49 +0200 Subject: [PATCH 24/40] save operation filter state unto global redux store --- .../audit/components/OperationFilter.tsx | 30 +++++++++++++++++-- .../audit/components/TimestampFilter.tsx | 5 ---- frontend/apps/ui/src/features/ui/uiSlice.ts | 27 ++++++++++++++++- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx b/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx index 61dd21541..fb8cbdac2 100644 --- a/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx @@ -1,17 +1,42 @@ +import {useAppDispatch, useAppSelector} from "@/app/hooks" +import { + auditLogOperationFilterValueUpdated, + selectAuditLogOperationFilterValue +} from "@/features/ui/uiSlice" +import {usePanelMode} from "@/hooks" import {MultiSelect, Paper} from "@mantine/core" -import React from "react" -import type {AuditLogQueryParams} from "../types" +import React, {useEffect} from "react" +import type {AuditLogQueryParams, AuditOperation} from "../types" interface Args { setQueryParams: React.Dispatch> } export default function OperationFilter({setQueryParams}: Args) { + const mode = usePanelMode() + const dispatch = useAppDispatch() + const operations = useAppSelector(s => + selectAuditLogOperationFilterValue(s, mode) + ) + + useEffect(() => { + setQueryParams(prev => ({ + ...prev, + filter_operation: operations?.join(",") + })) + }, [operations]) + const onChange = (values: string[]) => { setQueryParams(prev => ({ ...prev, filter_operation: values.join(",") })) + dispatch( + auditLogOperationFilterValueUpdated({ + mode, + value: values as Array + }) + ) } return ( @@ -21,6 +46,7 @@ export default function OperationFilter({setQueryParams}: Args) { placeholder="Pick value" clearable onChange={onChange} + value={operations} data={["INSERT", "DELETE", "UPDATE", "TRUNCATE"]} /> diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx index 71f4d6cb3..b0f14af7b 100644 --- a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx @@ -10,11 +10,6 @@ import { import {usePanelMode} from "@/hooks" import React, {useEffect} from "react" -interface TimestampRange { - from?: Date | null - to?: Date | null -} - interface TimestampPickerProps { setQueryParams: React.Dispatch> } diff --git a/frontend/apps/ui/src/features/ui/uiSlice.ts b/frontend/apps/ui/src/features/ui/uiSlice.ts index c3cf16e66..fbe6d8e6a 100644 --- a/frontend/apps/ui/src/features/ui/uiSlice.ts +++ b/frontend/apps/ui/src/features/ui/uiSlice.ts @@ -960,6 +960,19 @@ const uiSlice = createSlice({ } state.secondaryAuditLogTimestampFilterValue = value + }, + + auditLogOperationFilterValueUpdated( + state, + action: PayloadAction<{mode: PanelMode; value: Array}> + ) { + const {mode, value} = action.payload + if (mode == "main") { + state.mainAuditLogOperationFilterValue = value + return + } + + state.secondaryAuditLogOperationFilterValue = value } } }) @@ -1014,7 +1027,8 @@ export const { viewerPageHaveChangedDialogVisibilityChanged, auditLogVisibleFilterUpdated, auditLogFiltersCollapseUpdated, - auditLogTimestampFilterValueUpdated + auditLogTimestampFilterValueUpdated, + auditLogOperationFilterValueUpdated } = uiSlice.actions export default uiSlice.reducer @@ -1449,6 +1463,17 @@ export const selectAuditLogTimestampFilterValue = ( return state.ui.secondaryAuditLogTimestampFilterValue } +export const selectAuditLogOperationFilterValue = ( + state: RootState, + mode: PanelMode +) => { + if (mode == "main") { + return state.ui.mainAuditLogOperationFilterValue + } + + return state.ui.secondaryAuditLogOperationFilterValue +} + /* Load initial collapse state value from cookie */ function initial_collapse_value(): boolean { const collapsed = Cookies.get(NAVBAR_COLLAPSED_COOKIE) as BooleanString From 17e6bbb41c944f4cff618a3db37a2c2075d311bb Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Tue, 26 Aug 2025 05:51:26 +0200 Subject: [PATCH 25/40] make user_id/username not null --- .../audit/components/FilterSelector.tsx | 2 +- .../src/features/audit/hooks/useFilterList.ts | 4 ++- ...7f1e0d__v3_6_0__0001__add_audit_columns.py | 2 +- ...552ae28406c__v3_6_0__0002__misc_changes.py | 2 +- ...__add_nullable_false_to_created_at_and_.py | 2 +- ...0__0006__add_association_tables_trigger.py | 2 +- ..._make_user_id_username_columns_not_null.py | 35 +++++++++++++++++++ papermerge/core/features/audit/db/orm.py | 4 +-- papermerge/core/features/tags/router.py | 24 +++++++++++-- 9 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 papermerge/core/alembic/versions/f3c6512081b1__v3_6_0__0007__make_user_id_username_columns_not_null.py diff --git a/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx b/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx index 5972f0626..acc66fd77 100644 --- a/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx +++ b/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx @@ -1,8 +1,8 @@ +import useFilterList from "@/features/audit/hooks/useFilterList" import type {FilterListConfig} from "@/features/audit/types" import {ActionIcon, Checkbox, Popover, ScrollArea, Stack} from "@mantine/core" import {IconFilter} from "@tabler/icons-react" import {useState} from "react" -import useFilterList from "../hooks/useFilterList" interface FilterSelectorArgs { onChange: (items: FilterListConfig[]) => void diff --git a/frontend/apps/ui/src/features/audit/hooks/useFilterList.ts b/frontend/apps/ui/src/features/audit/hooks/useFilterList.ts index 3d4cddf33..14bd1e706 100644 --- a/frontend/apps/ui/src/features/audit/hooks/useFilterList.ts +++ b/frontend/apps/ui/src/features/audit/hooks/useFilterList.ts @@ -20,7 +20,9 @@ export default function useFilterList(): FilterListConfig[] { useEffect(() => { const newFilters = FILTERS.map(f => { - const visible = selectedFilterKeys && selectedFilterKeys.includes(f.key) + const visible = Boolean( + selectedFilterKeys && selectedFilterKeys.includes(f.key) + ) return {...f, visible} }) diff --git a/papermerge/core/alembic/versions/2973d27f1e0d__v3_6_0__0001__add_audit_columns.py b/papermerge/core/alembic/versions/2973d27f1e0d__v3_6_0__0001__add_audit_columns.py index faa4d3b0f..5180fe24a 100644 --- a/papermerge/core/alembic/versions/2973d27f1e0d__v3_6_0__0001__add_audit_columns.py +++ b/papermerge/core/alembic/versions/2973d27f1e0d__v3_6_0__0001__add_audit_columns.py @@ -1,4 +1,4 @@ -"""v3.6: 0001: add audit columns +"""v3.6.0: 0001 -- add audit columns Revision ID: 2973d27f1e0d Revises: 50ef5a403d27 diff --git a/papermerge/core/alembic/versions/5552ae28406c__v3_6_0__0002__misc_changes.py b/papermerge/core/alembic/versions/5552ae28406c__v3_6_0__0002__misc_changes.py index 2b9eb6ac5..df03843fc 100644 --- a/papermerge/core/alembic/versions/5552ae28406c__v3_6_0__0002__misc_changes.py +++ b/papermerge/core/alembic/versions/5552ae28406c__v3_6_0__0002__misc_changes.py @@ -1,4 +1,4 @@ -"""3.6.0: 0002 -- misc changes +"""v3.6.0: 0002 -- misc changes Revision ID: 5552ae28406c Revises: 2973d27f1e0d diff --git a/papermerge/core/alembic/versions/62bd9b006c5a__v3_6_0__0005__add_nullable_false_to_created_at_and_.py b/papermerge/core/alembic/versions/62bd9b006c5a__v3_6_0__0005__add_nullable_false_to_created_at_and_.py index 95f1caf6f..97f377a9c 100644 --- a/papermerge/core/alembic/versions/62bd9b006c5a__v3_6_0__0005__add_nullable_false_to_created_at_and_.py +++ b/papermerge/core/alembic/versions/62bd9b006c5a__v3_6_0__0005__add_nullable_false_to_created_at_and_.py @@ -1,4 +1,4 @@ -"""v3.6.0: 0005-- add nullable=False to created_at and updated_at +"""v3.6.0: 0005 -- add nullable=False to created_at and updated_at Revision ID: 62bd9b006c5a Revises: 93c83c34d446 diff --git a/papermerge/core/alembic/versions/ed681f3a1768__v3_6_0__0006__add_association_tables_trigger.py b/papermerge/core/alembic/versions/ed681f3a1768__v3_6_0__0006__add_association_tables_trigger.py index cd250018b..fd4b1a565 100644 --- a/papermerge/core/alembic/versions/ed681f3a1768__v3_6_0__0006__add_association_tables_trigger.py +++ b/papermerge/core/alembic/versions/ed681f3a1768__v3_6_0__0006__add_association_tables_trigger.py @@ -1,4 +1,4 @@ -"""v3.6.0 - 0006: add association tables trigger +"""v3.6.0: 0006 -- add association tables trigger Revision ID: ed681f3a1768 Revises: 62bd9b006c5a diff --git a/papermerge/core/alembic/versions/f3c6512081b1__v3_6_0__0007__make_user_id_username_columns_not_null.py b/papermerge/core/alembic/versions/f3c6512081b1__v3_6_0__0007__make_user_id_username_columns_not_null.py new file mode 100644 index 000000000..0144d51e2 --- /dev/null +++ b/papermerge/core/alembic/versions/f3c6512081b1__v3_6_0__0007__make_user_id_username_columns_not_null.py @@ -0,0 +1,35 @@ +"""v3.6.0: 0007 -- make user_id, username columns not NULL + +Revision ID: f3c6512081b1 +Revises: ed681f3a1768 +Create Date: 2025-08-26 05:43:20.201923 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'f3c6512081b1' +down_revision: Union[str, None] = 'ed681f3a1768' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column('audit_log', 'user_id', + existing_type=sa.UUID(), + nullable=False) + op.alter_column('audit_log', 'username', + existing_type=sa.VARCHAR(length=150), + nullable=False) + + +def downgrade() -> None: + op.alter_column('audit_log', 'username', + existing_type=sa.VARCHAR(length=150), + nullable=True) + op.alter_column('audit_log', 'user_id', + existing_type=sa.UUID(), + nullable=True) diff --git a/papermerge/core/features/audit/db/orm.py b/papermerge/core/features/audit/db/orm.py index d9759de6d..52e2f6a26 100644 --- a/papermerge/core/features/audit/db/orm.py +++ b/papermerge/core/features/audit/db/orm.py @@ -33,8 +33,8 @@ class AuditLog(Base): ) # Who (multiple sources of truth) - user_id: Mapped[Optional[UUID]] = mapped_column(PG_UUID(as_uuid=True), nullable=True) # App user - username: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) # App username + user_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False) + username: Mapped[str] = mapped_column(String(150), nullable=False) session_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Session tracking # Where/How diff --git a/papermerge/core/features/tags/router.py b/papermerge/core/features/tags/router.py index 42cd6aa23..f05083c13 100644 --- a/papermerge/core/features/tags/router.py +++ b/papermerge/core/features/tags/router.py @@ -15,6 +15,7 @@ from papermerge.core.features.tags import schema as tags_schema from papermerge.core.exceptions import EntityNotFound from papermerge.core.routers.common import OPEN_API_GENERIC_JSON_DETAIL +from papermerge.core.features.audit.db.audit_context import AsyncAuditContext from .types import PaginatedQueryParams router = APIRouter( @@ -147,7 +148,13 @@ async def create_tag( if not ok: detail = f"User {user.id=} does not belong to group {group_id=}" raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) - tag, error = await tags_dbapi.create_tag(db_session, attrs=attrs) + + async with AsyncAuditContext( + db_session, + user_id=user.id, + username=user.username + ): + tag, error = await tags_dbapi.create_tag(db_session, attrs=attrs) if error: raise HTTPException(status_code=400, detail=error.model_dump()) @@ -169,7 +176,12 @@ async def delete_tag( Required scope: `{scope}` """ try: - await tags_dbapi.delete_tag(db_session, tag_id=tag_id) + async with AsyncAuditContext( + db_session, + user_id=user.id, + username=user.username + ): + await tags_dbapi.delete_tag(db_session, tag_id=tag_id) except EntityNotFound: raise HTTPException(status_code=404, detail="Does not exists") @@ -206,7 +218,13 @@ async def update_tag( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) else: attrs.user_id = user.id - tag, error = await tags_dbapi.update_tag(db_session, tag_id=tag_id, attrs=attrs) + + async with AsyncAuditContext( + db_session, + user_id=user.id, + username=user.username + ): + tag, error = await tags_dbapi.update_tag(db_session, tag_id=tag_id, attrs=attrs) if error: raise HTTPException(status_code=400, detail=error.model_dump()) From 5358efd835f94096cba58e3eaead8c2f62baea08 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Tue, 26 Aug 2025 07:06:05 +0200 Subject: [PATCH 26/40] filters..., filters... --- .../audit/components/FiltersCollapse.tsx | 43 +++++++++++--- .../audit/components/OperationFilter.tsx | 29 +++++++++- .../audit/components/TimestampFilter.tsx | 57 ++++++++++++++++++- .../apps/ui/src/features/audit/constants.ts | 4 ++ .../src/features/audit/hooks/useFilterList.ts | 17 ++---- frontend/apps/ui/src/features/ui/uiSlice.ts | 28 ++++++++- 6 files changed, 155 insertions(+), 23 deletions(-) create mode 100644 frontend/apps/ui/src/features/audit/constants.ts diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx index 45eaa5c1d..7de590d22 100644 --- a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -2,19 +2,27 @@ import {useAppDispatch, useAppSelector} from "@/app/hooks" import useFilterList from "@/features/audit/hooks/useFilterList" import { auditLogFiltersCollapseUpdated, + auditLogTimestampFilterValueCleared, selectAuditLogFiltersCollapse } from "@/features/ui/uiSlice" import {Box, Group, Paper, UnstyledButton} from "@mantine/core" import {IconChevronDown} from "@tabler/icons-react" -import React, {forwardRef} from "react" +import React, {forwardRef, useEffect} from "react" import type {AuditLogQueryParams} from "../types" import {usePanelMode} from "@/hooks" -import OperationFilter from "./OperationFilter" +import OperationFilter, {useOperationFilter} from "./OperationFilter" import TableNameFilter from "./TableNameFilter" -import TimestampFilter from "./TimestampFilter" +import TimestampFilter, {useTimestampFilter} from "./TimestampFilter" import UserFilter from "./UserFilter" +import { + OPERATION_FILTER_KEY, + TABLE_NAME_FILTER_KEY, + TIMESTAMP_FILTER_KEY, + USER_FILTER_KEY +} from "@/features/audit/constants" + interface Args { className?: string setQueryParams: React.Dispatch> @@ -28,10 +36,31 @@ const FiltersCollapse = forwardRef( const visibleFiltersOnly = filtersList.filter(f => f.visible) const expanded = useAppSelector(s => selectAuditLogFiltersCollapse(s, mode)) + /* If user opens audit log table with closed i.e.none + of the filters visible - then filters should be applied anyway */ + const {clear: clearTimestampFilter} = useTimestampFilter({setQueryParams}) + const {clear: clearOperationFilter} = useOperationFilter({setQueryParams}) + const toggleExpanded = () => { dispatch(auditLogFiltersCollapseUpdated({value: !expanded, mode})) } + useEffect(() => { + filtersList.forEach(f => { + if (!f.visible) { + switch (f.key) { + case TIMESTAMP_FILTER_KEY: + dispatch( + auditLogTimestampFilterValueCleared({ + mode + }) + ) + clearTimestampFilter() + } + } + }) + }, [filtersList]) + let filterComponents: React.ReactElement[] = [] if (visibleFiltersOnly.length == 0) { @@ -41,22 +70,22 @@ const FiltersCollapse = forwardRef( visibleFiltersOnly.forEach(f => { if (f.visible) { switch (f.key) { - case "timestamp": + case TIMESTAMP_FILTER_KEY: filterComponents.push( ) break - case "operation": + case OPERATION_FILTER_KEY: filterComponents.push( ) break - case "table_name": + case TABLE_NAME_FILTER_KEY: filterComponents.push( ) break - case "user": + case USER_FILTER_KEY: filterComponents.push() break } diff --git a/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx b/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx index fb8cbdac2..513bbc7a4 100644 --- a/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx @@ -1,4 +1,5 @@ import {useAppDispatch, useAppSelector} from "@/app/hooks" +import type {AuditLogQueryParams, AuditOperation} from "@/features/audit/types" import { auditLogOperationFilterValueUpdated, selectAuditLogOperationFilterValue @@ -6,12 +7,38 @@ import { import {usePanelMode} from "@/hooks" import {MultiSelect, Paper} from "@mantine/core" import React, {useEffect} from "react" -import type {AuditLogQueryParams, AuditOperation} from "../types" interface Args { setQueryParams: React.Dispatch> } +interface ReturnValue { + clear: () => void +} + +export function useOperationFilter({setQueryParams}: Args): ReturnValue { + const mode = usePanelMode() + const operations = useAppSelector(s => + selectAuditLogOperationFilterValue(s, mode) + ) + + const clear = () => { + setQueryParams(prev => ({ + ...prev, + filter_operation: undefined + })) + } + + useEffect(() => { + setQueryParams(prev => ({ + ...prev, + filter_operation: operations?.join(",") + })) + }, []) + + return {clear} +} + export default function OperationFilter({setQueryParams}: Args) { const mode = usePanelMode() const dispatch = useAppDispatch() diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx index b0f14af7b..312225417 100644 --- a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx @@ -10,11 +10,62 @@ import { import {usePanelMode} from "@/hooks" import React, {useEffect} from "react" -interface TimestampPickerProps { +interface TimestampFilterArgs { setQueryParams: React.Dispatch> } -const TimestampPicker: React.FC = ({setQueryParams}) => { +interface TimestampFilterReturn { + clear: () => void +} + +export function useTimestampFilter({ + setQueryParams +}: TimestampFilterArgs): TimestampFilterReturn { + const mode = usePanelMode() + const range = useAppSelector(s => selectAuditLogTimestampFilterValue(s, mode)) + + const clear = () => { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_from: undefined + })) + + setQueryParams(prev => ({ + ...prev, + filter_timestamp_to: undefined + })) + } + + useEffect(() => { + if (range?.from) { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_from: range?.from || undefined + })) + } else { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_from: undefined + })) + } + + if (range?.to) { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_to: range?.to || undefined + })) + } else { + setQueryParams(prev => ({ + ...prev, + filter_timestamp_to: undefined + })) + } + }, [range]) + + return {clear} +} + +const TimestampFilter: React.FC = ({setQueryParams}) => { const mode = usePanelMode() const dispatch = useAppDispatch() const range = useAppSelector(s => selectAuditLogTimestampFilterValue(s, mode)) @@ -174,4 +225,4 @@ const TimestampPicker: React.FC = ({setQueryParams}) => { ) } -export default TimestampPicker +export default TimestampFilter diff --git a/frontend/apps/ui/src/features/audit/constants.ts b/frontend/apps/ui/src/features/audit/constants.ts new file mode 100644 index 000000000..4e6432234 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/constants.ts @@ -0,0 +1,4 @@ +export const TIMESTAMP_FILTER_KEY = "timestamp" +export const OPERATION_FILTER_KEY = "operation" +export const TABLE_NAME_FILTER_KEY = "table_name" +export const USER_FILTER_KEY = "user" diff --git a/frontend/apps/ui/src/features/audit/hooks/useFilterList.ts b/frontend/apps/ui/src/features/audit/hooks/useFilterList.ts index 14bd1e706..74bcaa6e3 100644 --- a/frontend/apps/ui/src/features/audit/hooks/useFilterList.ts +++ b/frontend/apps/ui/src/features/audit/hooks/useFilterList.ts @@ -2,7 +2,7 @@ import {useAppSelector} from "@/app/hooks" import type {FilterListConfig} from "@/features/audit/types" import {selectAuditLogVisibleFilters} from "@/features/ui/uiSlice" import {usePanelMode} from "@/hooks" -import {useEffect, useState} from "react" +import {useMemo} from "react" const FILTERS: FilterListConfig[] = [ {key: "timestamp", label: "Timestamp", visible: false}, @@ -16,17 +16,12 @@ export default function useFilterList(): FilterListConfig[] { const selectedFilterKeys = useAppSelector(s => selectAuditLogVisibleFilters(s, mode) ) - const [filtersList, setFiltersList] = useState(FILTERS) - useEffect(() => { - const newFilters = FILTERS.map(f => { - const visible = Boolean( - selectedFilterKeys && selectedFilterKeys.includes(f.key) - ) - return {...f, visible} - }) - - setFiltersList(newFilters) + const filtersList = useMemo(() => { + return FILTERS.map(filter => ({ + ...filter, + visible: Boolean(selectedFilterKeys?.includes(filter.key)) + })) }, [selectedFilterKeys]) return filtersList diff --git a/frontend/apps/ui/src/features/ui/uiSlice.ts b/frontend/apps/ui/src/features/ui/uiSlice.ts index fbe6d8e6a..5c8291913 100644 --- a/frontend/apps/ui/src/features/ui/uiSlice.ts +++ b/frontend/apps/ui/src/features/ui/uiSlice.ts @@ -961,6 +961,18 @@ const uiSlice = createSlice({ state.secondaryAuditLogTimestampFilterValue = value }, + auditLogTimestampFilterValueCleared( + state, + action: PayloadAction<{mode: PanelMode}> + ) { + const {mode} = action.payload + if (mode == "main") { + state.mainAuditLogTimestampFilterValue = undefined + return + } + + state.secondaryAuditLogTimestampFilterValue = undefined + }, auditLogOperationFilterValueUpdated( state, @@ -973,6 +985,18 @@ const uiSlice = createSlice({ } state.secondaryAuditLogOperationFilterValue = value + }, + auditLogOperationFilterValueCleared( + state, + action: PayloadAction<{mode: PanelMode}> + ) { + const {mode} = action.payload + if (mode == "main") { + state.mainAuditLogOperationFilterValue = undefined + return + } + + state.secondaryAuditLogOperationFilterValue = undefined } } }) @@ -1028,7 +1052,9 @@ export const { auditLogVisibleFilterUpdated, auditLogFiltersCollapseUpdated, auditLogTimestampFilterValueUpdated, - auditLogOperationFilterValueUpdated + auditLogTimestampFilterValueCleared, + auditLogOperationFilterValueUpdated, + auditLogOperationFilterValueCleared } = uiSlice.actions export default uiSlice.reducer From f6b6abad265caa2c1e0829629b798b98b5ac833d Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Tue, 26 Aug 2025 07:19:26 +0200 Subject: [PATCH 27/40] clear operation filter --- .../src/features/audit/components/FiltersCollapse.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx index 7de590d22..e769a817d 100644 --- a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -2,6 +2,7 @@ import {useAppDispatch, useAppSelector} from "@/app/hooks" import useFilterList from "@/features/audit/hooks/useFilterList" import { auditLogFiltersCollapseUpdated, + auditLogOperationFilterValueCleared, auditLogTimestampFilterValueCleared, selectAuditLogFiltersCollapse } from "@/features/ui/uiSlice" @@ -56,6 +57,15 @@ const FiltersCollapse = forwardRef( }) ) clearTimestampFilter() + break + case OPERATION_FILTER_KEY: + dispatch( + auditLogOperationFilterValueCleared({ + mode + }) + ) + clearOperationFilter() + break } } }) From 9761dc77005a5d27ee78294fe66a99dc747a049e Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Tue, 26 Aug 2025 07:51:32 +0200 Subject: [PATCH 28/40] fix minor pagination issues --- frontend/apps/ui/src/features/audit/components/List.tsx | 4 ++-- .../packages/kommon/src/components/Table/TablePagination.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index fa2b9759c..a3a8372f9 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -99,9 +99,9 @@ export default function AuditLogsList() { ( pageSize, onPageChange, onPageSizeChange, - pageSizeOptions = [10, 15, 25, 50, 100], + pageSizeOptions = [5, 10, 15, 25, 50, 100], showPageSizeSelector = true, totalItems }, From 0078cd312aace67cce00f779e80d6a47e966e957 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Tue, 26 Aug 2025 07:56:09 +0200 Subject: [PATCH 29/40] fix minor issue --- .../apps/ui/src/features/audit/components/TimestampFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx index 312225417..f2cf758a6 100644 --- a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx @@ -82,7 +82,7 @@ const TimestampFilter: React.FC = ({setQueryParams}) => { auditLogTimestampFilterValueUpdated({ mode, value: { - from: newFrom?.toDateString() || null, + from: newFrom?.toISOString() || null, to: range?.to || null } }) From b7741c8c91b86b663c77f75f1e15d6e31e33e682 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Tue, 26 Aug 2025 08:23:46 +0200 Subject: [PATCH 30/40] add table_names filter --- .../audit/components/FiltersCollapse.tsx | 12 ++- .../audit/components/OperationFilter.tsx | 12 +-- .../audit/components/TableNameFilter.tsx | 78 +++++++++++++++---- .../audit/components/TimestampFilter.tsx | 12 +-- frontend/apps/ui/src/features/audit/types.ts | 4 + frontend/apps/ui/src/features/ui/uiSlice.ts | 40 +++++++++- 6 files changed, 127 insertions(+), 31 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx index e769a817d..f727ef1a9 100644 --- a/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx +++ b/frontend/apps/ui/src/features/audit/components/FiltersCollapse.tsx @@ -3,6 +3,7 @@ import useFilterList from "@/features/audit/hooks/useFilterList" import { auditLogFiltersCollapseUpdated, auditLogOperationFilterValueCleared, + auditLogTableNameFilterValueCleared, auditLogTimestampFilterValueCleared, selectAuditLogFiltersCollapse } from "@/features/ui/uiSlice" @@ -13,7 +14,7 @@ import type {AuditLogQueryParams} from "../types" import {usePanelMode} from "@/hooks" import OperationFilter, {useOperationFilter} from "./OperationFilter" -import TableNameFilter from "./TableNameFilter" +import TableNameFilter, {useTableNameFilter} from "./TableNameFilter" import TimestampFilter, {useTimestampFilter} from "./TimestampFilter" import UserFilter from "./UserFilter" @@ -41,6 +42,7 @@ const FiltersCollapse = forwardRef( of the filters visible - then filters should be applied anyway */ const {clear: clearTimestampFilter} = useTimestampFilter({setQueryParams}) const {clear: clearOperationFilter} = useOperationFilter({setQueryParams}) + const {clear: clearTableNameFilter} = useTableNameFilter({setQueryParams}) const toggleExpanded = () => { dispatch(auditLogFiltersCollapseUpdated({value: !expanded, mode})) @@ -66,6 +68,14 @@ const FiltersCollapse = forwardRef( ) clearOperationFilter() break + case TABLE_NAME_FILTER_KEY: + dispatch( + auditLogTableNameFilterValueCleared({ + mode + }) + ) + clearTableNameFilter() + break } } }) diff --git a/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx b/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx index 513bbc7a4..b00e786cc 100644 --- a/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx @@ -1,5 +1,9 @@ import {useAppDispatch, useAppSelector} from "@/app/hooks" -import type {AuditLogQueryParams, AuditOperation} from "@/features/audit/types" +import type { + AuditLogQueryParams, + AuditOperation, + FilterHookReturn +} from "@/features/audit/types" import { auditLogOperationFilterValueUpdated, selectAuditLogOperationFilterValue @@ -12,11 +16,7 @@ interface Args { setQueryParams: React.Dispatch> } -interface ReturnValue { - clear: () => void -} - -export function useOperationFilter({setQueryParams}: Args): ReturnValue { +export function useOperationFilter({setQueryParams}: Args): FilterHookReturn { const mode = usePanelMode() const operations = useAppSelector(s => selectAuditLogOperationFilterValue(s, mode) diff --git a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx index db05aaefd..97226453e 100644 --- a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx @@ -1,17 +1,58 @@ +import {useAppDispatch, useAppSelector} from "@/app/hooks" +import { + auditLogTableNameFilterValueUpdated, + selectAuditLogTableNameFilterValue +} from "@/features/ui/uiSlice" +import {usePanelMode} from "@/hooks" import {MultiSelect, Paper} from "@mantine/core" -import React from "react" -import type {AuditLogQueryParams} from "../types" +import React, {useEffect} from "react" +import type {AuditLogQueryParams, FilterHookReturn} from "../types" interface Args { setQueryParams: React.Dispatch> } +export function useTableNameFilter({setQueryParams}: Args): FilterHookReturn { + const mode = usePanelMode() + const table_names = useAppSelector(s => + selectAuditLogTableNameFilterValue(s, mode) + ) + + const clear = () => { + setQueryParams(prev => ({ + ...prev, + filter_table_name: undefined + })) + } + + useEffect(() => { + setQueryParams(prev => ({ + ...prev, + filter_table_name: table_names?.join(",") + })) + }, []) + + return {clear} +} + export default function TableNameFilter({setQueryParams}: Args) { + const mode = usePanelMode() + const dispatch = useAppDispatch() + const table_names = useAppSelector(s => + selectAuditLogTableNameFilterValue(s, mode) + ) + const onChange = (values: string[]) => { setQueryParams(prev => ({ ...prev, filter_table_name: values.join(",") })) + dispatch( + auditLogTableNameFilterValueUpdated({ + mode, + value: values + }) + ) } return ( @@ -22,22 +63,25 @@ export default function TableNameFilter({setQueryParams}: Args) { placeholder="Pick value" clearable onChange={onChange} - data={[ - "nodes", - "document_versions", - "custom_fields", - "document_types", - "shared_nodes", - "tags", - "users", - "groups", - "users_roles", - "users_groups", - "roles_permissions", - "nodes_tags", - "document_types_custom_fields" - ].sort()} + value={table_names} + data={ALL_TABLES} /> ) } + +const ALL_TABLES = [ + "nodes", + "document_versions", + "custom_fields", + "document_types", + "shared_nodes", + "tags", + "users", + "groups", + "users_roles", + "users_groups", + "roles_permissions", + "nodes_tags", + "document_types_custom_fields" +].sort() diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx index f2cf758a6..0f2848d4e 100644 --- a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx @@ -1,6 +1,10 @@ +import type { + AuditLogQueryParams, + FilterHookReturn, + TimestampFilterType +} from "@/features/audit/types" import {Button, Group, Paper, Stack} from "@mantine/core" import {DateTimePicker} from "@mantine/dates" -import type {AuditLogQueryParams, TimestampFilterType} from "../types" import {useAppDispatch, useAppSelector} from "@/app/hooks" import { @@ -14,13 +18,9 @@ interface TimestampFilterArgs { setQueryParams: React.Dispatch> } -interface TimestampFilterReturn { - clear: () => void -} - export function useTimestampFilter({ setQueryParams -}: TimestampFilterArgs): TimestampFilterReturn { +}: TimestampFilterArgs): FilterHookReturn { const mode = usePanelMode() const range = useAppSelector(s => selectAuditLogTimestampFilterValue(s, mode)) diff --git a/frontend/apps/ui/src/features/audit/types.ts b/frontend/apps/ui/src/features/audit/types.ts index d92389b24..d0fe8947f 100644 --- a/frontend/apps/ui/src/features/audit/types.ts +++ b/frontend/apps/ui/src/features/audit/types.ts @@ -60,3 +60,7 @@ export type TimestampFilterType = { from: string | null // Date().toISOString() to: string | null // Date().toISOString() } + +export interface FilterHookReturn { + clear: () => void +} diff --git a/frontend/apps/ui/src/features/ui/uiSlice.ts b/frontend/apps/ui/src/features/ui/uiSlice.ts index 5c8291913..e4c758563 100644 --- a/frontend/apps/ui/src/features/ui/uiSlice.ts +++ b/frontend/apps/ui/src/features/ui/uiSlice.ts @@ -997,6 +997,31 @@ const uiSlice = createSlice({ } state.secondaryAuditLogOperationFilterValue = undefined + }, + + auditLogTableNameFilterValueUpdated( + state, + action: PayloadAction<{mode: PanelMode; value: Array}> + ) { + const {mode, value} = action.payload + if (mode == "main") { + state.mainAuditLogTableNameFilterValue = value + return + } + + state.secondaryAuditLogTableNameFilterValue = value + }, + auditLogTableNameFilterValueCleared( + state, + action: PayloadAction<{mode: PanelMode}> + ) { + const {mode} = action.payload + if (mode == "main") { + state.mainAuditLogTableNameFilterValue = undefined + return + } + + state.secondaryAuditLogTableNameFilterValue = undefined } } }) @@ -1054,7 +1079,9 @@ export const { auditLogTimestampFilterValueUpdated, auditLogTimestampFilterValueCleared, auditLogOperationFilterValueUpdated, - auditLogOperationFilterValueCleared + auditLogOperationFilterValueCleared, + auditLogTableNameFilterValueCleared, + auditLogTableNameFilterValueUpdated } = uiSlice.actions export default uiSlice.reducer @@ -1500,6 +1527,17 @@ export const selectAuditLogOperationFilterValue = ( return state.ui.secondaryAuditLogOperationFilterValue } +export const selectAuditLogTableNameFilterValue = ( + state: RootState, + mode: PanelMode +) => { + if (mode == "main") { + return state.ui.mainAuditLogTableNameFilterValue + } + + return state.ui.secondaryAuditLogTableNameFilterValue +} + /* Load initial collapse state value from cookie */ function initial_collapse_value(): boolean { const collapsed = Cookies.get(NAVBAR_COLLAPSED_COOKIE) as BooleanString From 37e5031fafef71d9f88095fc766a5df772e253ec Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Tue, 26 Aug 2025 08:42:54 +0200 Subject: [PATCH 31/40] opti --- .../audit/components/TableNameFilter.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx index 97226453e..53cd88c57 100644 --- a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx @@ -42,6 +42,22 @@ export default function TableNameFilter({setQueryParams}: Args) { selectAuditLogTableNameFilterValue(s, mode) ) + const ALL_TABLES = [ + "nodes", + "document_versions", + "custom_fields", + "document_types", + "shared_nodes", + "tags", + "users", + "groups", + "users_roles", + "users_groups", + "roles_permissions", + "nodes_tags", + "document_types_custom_fields" + ] + const onChange = (values: string[]) => { setQueryParams(prev => ({ ...prev, @@ -69,19 +85,3 @@ export default function TableNameFilter({setQueryParams}: Args) { ) } - -const ALL_TABLES = [ - "nodes", - "document_versions", - "custom_fields", - "document_types", - "shared_nodes", - "tags", - "users", - "groups", - "users_roles", - "users_groups", - "roles_permissions", - "nodes_tags", - "document_types_custom_fields" -].sort() From 2b9bcf0b19e9a441ca865932a65cdaa5025084b6 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Tue, 26 Aug 2025 08:49:54 +0200 Subject: [PATCH 32/40] optimizations --- .../audit/components/TableNameFilter.tsx | 85 ++++--- .../audit/components/TimestampFilter.tsx | 214 +++++++++--------- 2 files changed, 154 insertions(+), 145 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx index 53cd88c57..b1f96a36d 100644 --- a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx @@ -5,74 +5,89 @@ import { } from "@/features/ui/uiSlice" import {usePanelMode} from "@/hooks" import {MultiSelect, Paper} from "@mantine/core" -import React, {useEffect} from "react" +import React, {useCallback, useEffect, useMemo} from "react" import type {AuditLogQueryParams, FilterHookReturn} from "../types" interface Args { setQueryParams: React.Dispatch> } +// Move static data outside component to prevent recreation +const ALL_TABLES = [ + "nodes", + "document_versions", + "custom_fields", + "document_types", + "shared_nodes", + "tags", + "users", + "groups", + "users_roles", + "users_groups", + "roles_permissions", + "nodes_tags", + "document_types_custom_fields" +] as const + export function useTableNameFilter({setQueryParams}: Args): FilterHookReturn { const mode = usePanelMode() const table_names = useAppSelector(s => selectAuditLogTableNameFilterValue(s, mode) ) - const clear = () => { + // Memoize the clear function to prevent unnecessary re-renders + const clear = useCallback(() => { setQueryParams(prev => ({ ...prev, filter_table_name: undefined })) - } + }, [setQueryParams]) useEffect(() => { setQueryParams(prev => ({ ...prev, filter_table_name: table_names?.join(",") })) - }, []) + }, [setQueryParams, table_names]) // Add dependencies return {clear} } -export default function TableNameFilter({setQueryParams}: Args) { +// Memoize the main component +const TableNameFilter = React.memo(function TableNameFilter({ + setQueryParams +}: Args) { const mode = usePanelMode() const dispatch = useAppDispatch() const table_names = useAppSelector(s => selectAuditLogTableNameFilterValue(s, mode) ) - const ALL_TABLES = [ - "nodes", - "document_versions", - "custom_fields", - "document_types", - "shared_nodes", - "tags", - "users", - "groups", - "users_roles", - "users_groups", - "roles_permissions", - "nodes_tags", - "document_types_custom_fields" - ] + // Memoize the onChange handler + const onChange = useCallback( + (values: string[]) => { + const joinedValues = values.join(",") - const onChange = (values: string[]) => { - setQueryParams(prev => ({ - ...prev, - filter_table_name: values.join(",") - })) - dispatch( - auditLogTableNameFilterValueUpdated({ - mode, - value: values - }) - ) - } + setQueryParams(prev => ({ + ...prev, + filter_table_name: joinedValues + })) + + dispatch( + auditLogTableNameFilterValueUpdated({ + mode, + value: values + }) + ) + }, + [setQueryParams, dispatch, mode] + ) + + // Memoize Paper wrapper props if they're complex (optional here) + const paperProps = useMemo(() => ({p: "xs" as const}), []) return ( - + ) -} +}) + +export default TableNameFilter diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx index 0f2848d4e..a49a0a826 100644 --- a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx @@ -12,7 +12,7 @@ import { selectAuditLogTimestampFilterValue } from "@/features/ui/uiSlice" import {usePanelMode} from "@/hooks" -import React, {useEffect} from "react" +import React, {useCallback, useEffect, useMemo} from "react" interface TimestampFilterArgs { setQueryParams: React.Dispatch> @@ -24,145 +24,131 @@ export function useTimestampFilter({ const mode = usePanelMode() const range = useAppSelector(s => selectAuditLogTimestampFilterValue(s, mode)) - const clear = () => { + const clear = useCallback(() => { + // Single batch update instead of two separate calls setQueryParams(prev => ({ ...prev, - filter_timestamp_from: undefined + filter_timestamp_from: undefined, + filter_timestamp_to: undefined })) + }, [setQueryParams]) + useEffect(() => { + // Single batch update instead of separate calls setQueryParams(prev => ({ ...prev, - filter_timestamp_to: undefined + filter_timestamp_from: range?.from || undefined, + filter_timestamp_to: range?.to || undefined })) - } - - useEffect(() => { - if (range?.from) { - setQueryParams(prev => ({ - ...prev, - filter_timestamp_from: range?.from || undefined - })) - } else { - setQueryParams(prev => ({ - ...prev, - filter_timestamp_from: undefined - })) - } - - if (range?.to) { - setQueryParams(prev => ({ - ...prev, - filter_timestamp_to: range?.to || undefined - })) - } else { - setQueryParams(prev => ({ - ...prev, - filter_timestamp_to: undefined - })) - } - }, [range]) + }, [range, setQueryParams]) return {clear} } -const TimestampFilter: React.FC = ({setQueryParams}) => { +// Memoize the main component +const TimestampFilter = React.memo(({setQueryParams}) => { const mode = usePanelMode() const dispatch = useAppDispatch() const range = useAppSelector(s => selectAuditLogTimestampFilterValue(s, mode)) - const onChangeFrom = (value: string | null) => { - setQueryParams(prev => ({ - ...prev, - filter_timestamp_from: value ? value : undefined - })) - - const newFrom = value ? new Date(value) : null - - dispatch( - auditLogTimestampFilterValueUpdated({ - mode, - value: { - from: newFrom?.toISOString() || null, - to: range?.to || null - } - }) - ) - } + // Memoize handlers to prevent unnecessary re-renders + const onChangeFrom = useCallback( + (value: string | null) => { + const newFrom = value ? new Date(value) : null - const onChangeTo = (value: string | null) => { - setQueryParams(prev => ({ - ...prev, - filter_timestamp_from: value ? value : undefined - })) - const newTo = value ? new Date(value) : null - - dispatch( - auditLogTimestampFilterValueUpdated({ - mode, - value: { - to: newTo?.toISOString() || null, - from: range?.from || null - } - }) - ) - } - - useEffect(() => { - if (range?.from) { setQueryParams(prev => ({ ...prev, - filter_timestamp_from: range?.from || undefined + filter_timestamp_from: value || undefined })) - } else { - setQueryParams(prev => ({ - ...prev, - filter_timestamp_from: undefined - })) - } - if (range?.to) { - setQueryParams(prev => ({ - ...prev, - filter_timestamp_to: range?.to || undefined - })) - } else { + dispatch( + auditLogTimestampFilterValueUpdated({ + mode, + value: { + from: newFrom?.toISOString() || null, + to: range?.to || null + } + }) + ) + }, + [setQueryParams, dispatch, mode, range?.to] + ) + + const onChangeTo = useCallback( + (value: string | null) => { + const newTo = value ? new Date(value) : null + + // BUG FIX: This was setting filter_timestamp_from instead of filter_timestamp_to setQueryParams(prev => ({ ...prev, - filter_timestamp_to: undefined + filter_timestamp_to: value || undefined // Fixed: was filter_timestamp_from })) - } - }, [range]) - const handleQuickSelect = (type: "today" | "1hour" | "3hours") => { + dispatch( + auditLogTimestampFilterValueUpdated({ + mode, + value: { + from: range?.from || null, + to: newTo?.toISOString() || null + } + }) + ) + }, + [setQueryParams, dispatch, mode, range?.from] + ) + + // Remove duplicate useEffect - already handled in hook + + // Memoize date calculations to prevent recalculation on every render + const quickSelectRanges = useMemo(() => { const now = new Date() - let newRange: TimestampFilterType = {from: null, to: null} - switch (type) { - case "today": + return { + today: () => { const startOfDay = new Date(now) startOfDay.setHours(0, 0, 0, 0) const endOfDay = new Date(now) endOfDay.setHours(23, 59, 59, 999) - newRange = {from: startOfDay.toISOString(), to: endOfDay.toISOString()} - break - case "1hour": + return {from: startOfDay.toISOString(), to: endOfDay.toISOString()} + }, + oneHour: () => { const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) - newRange = {from: oneHourAgo.toISOString(), to: now.toISOString()} - break - case "3hours": + return {from: oneHourAgo.toISOString(), to: now.toISOString()} + }, + threeHours: () => { const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000) - newRange = {from: threeHoursAgo.toISOString(), to: now.toISOString()} - break + return {from: threeHoursAgo.toISOString(), to: now.toISOString()} + } } - dispatch( - auditLogTimestampFilterValueUpdated({ - mode, - value: newRange - }) - ) - } + }, []) // Empty dependency - recalculate only when component mounts + + const handleQuickSelect = useCallback( + (type: "today" | "1hour" | "3hours") => { + let newRange: TimestampFilterType + + switch (type) { + case "today": + newRange = quickSelectRanges.today() + break + case "1hour": + newRange = quickSelectRanges.oneHour() + break + case "3hours": + newRange = quickSelectRanges.threeHours() + break + } + + dispatch( + auditLogTimestampFilterValueUpdated({ + mode, + value: newRange + }) + ) + }, + [dispatch, mode, quickSelectRanges] + ) - const handleClear = () => { + const handleClear = useCallback(() => { const newRange: TimestampFilterType = {from: null, to: null} dispatch( auditLogTimestampFilterValueUpdated({ @@ -170,12 +156,16 @@ const TimestampFilter: React.FC = ({setQueryParams}) => { value: newRange }) ) - } + }, [dispatch, mode]) + + // Memoize static props + const paperProps = useMemo(() => ({p: "xs" as const}), []) + const groupProps = useMemo(() => ({justify: "start" as const}), []) return ( - + - + = ({setQueryParams}) => { > Last 3 Hours - ) -} +}) + +TimestampFilter.displayName = "TimestampFilter" export default TimestampFilter From 16053dad12822982328fc59831d127faacb6fe1d Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Tue, 26 Aug 2025 20:47:15 +0200 Subject: [PATCH 33/40] use useCallback --- .../audit/components/FilterSelector.tsx | 17 ++++++++++------- .../ui/src/features/audit/components/List.tsx | 15 +++++++++------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx b/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx index acc66fd77..66a78291c 100644 --- a/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx +++ b/frontend/apps/ui/src/features/audit/components/FilterSelector.tsx @@ -2,7 +2,7 @@ import useFilterList from "@/features/audit/hooks/useFilterList" import type {FilterListConfig} from "@/features/audit/types" import {ActionIcon, Checkbox, Popover, ScrollArea, Stack} from "@mantine/core" import {IconFilter} from "@tabler/icons-react" -import {useState} from "react" +import {useCallback, useState} from "react" interface FilterSelectorArgs { onChange: (items: FilterListConfig[]) => void @@ -22,12 +22,15 @@ export default function DropdownSelector({onChange}: FilterSelectorArgs) { /> )) - const onLocalChange = (key: FilterListConfig["key"], checked: boolean) => { - const newItems = filtersList.map(i => - i.key === key ? {...i, visible: checked} : i - ) - onChange(newItems) - } + const onLocalChange = useCallback( + (key: FilterListConfig["key"], checked: boolean) => { + const newItems = filtersList.map(i => + i.key === key ? {...i, visible: checked} : i + ) + onChange(newItems) + }, + [filtersList, onChange] + ) return ( { - const visibleFilterKeys = items.filter(i => i.visible).map(i => i.key) + const onFilterVisibilityChange = useCallback( + (items: FilterListConfig[]) => { + const visibleFilterKeys = items.filter(i => i.visible).map(i => i.key) - dispatch( - auditLogVisibleFilterUpdated({filterKeys: visibleFilterKeys, mode}) - ) - } + dispatch( + auditLogVisibleFilterUpdated({filterKeys: visibleFilterKeys, mode}) + ) + }, + [dispatch, mode] + ) if (auditLogTable.isError) { return ( From 569978cf9cd384e2b45bdaf39f4b95669e22ca27 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Wed, 27 Aug 2025 07:49:42 +0200 Subject: [PATCH 34/40] wip --- .../ui/src/features/audit/components/List.tsx | 9 +- .../audit/components/OperationFilter2.tsx | 24 +++++ .../src/features/audit/components/Search.tsx | 54 +++++++++++ .../audit/components/TableNameFilter2.tsx | 41 ++++++++ .../audit/components/TimestampFilter2.tsx | 95 +++++++++++++++++++ 5 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 frontend/apps/ui/src/features/audit/components/OperationFilter2.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/Search.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/TableNameFilter2.tsx create mode 100644 frontend/apps/ui/src/features/audit/components/TimestampFilter2.tsx diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 22fe544c0..eaf631926 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -6,12 +6,11 @@ import type {SortState} from "kommon" import {useCallback, useRef} from "react" import type {AuditLogItem, FilterListConfig, SortBy} from "../types" import auditLogColumns from "./auditLogColumns" -import FilterSelector from "./FilterSelector" +import Search from "./Search" import useAuditLogTable from "./useAuditLogTable" import {usePanelMode} from "@/hooks" import {ColumnSelector, DataTable, TablePagination, useTableData} from "kommon" -import Filters from "./FiltersCollapse" export default function AuditLogsList() { const auditLogTable = useAuditLogTable() @@ -74,10 +73,8 @@ export default function AuditLogsList() { return ( - - - - + + void +} + +export default function OperationFilter({operations, onChange}: Args) { + return ( + e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + > + + + ) +} diff --git a/frontend/apps/ui/src/features/audit/components/Search.tsx b/frontend/apps/ui/src/features/audit/components/Search.tsx new file mode 100644 index 000000000..68828c811 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/Search.tsx @@ -0,0 +1,54 @@ +import {ActionIcon, Button, Group, Menu, Stack, TextInput} from "@mantine/core" +import {IconAdjustmentsHorizontal, IconSearch} from "@tabler/icons-react" +import OperationFilter from "./OperationFilter2" +import TableNameFilter from "./TableNameFilter2" +import TimestampFilter from "./TimestampFilter2" + +export default function Search() { + return ( + } + leftSectionPointerEvents="auto" + leftSection={ + + + + + + + + + + + + + + + + + + + + + + + } + placeholder="Search audit logs..." + /> + ) +} diff --git a/frontend/apps/ui/src/features/audit/components/TableNameFilter2.tsx b/frontend/apps/ui/src/features/audit/components/TableNameFilter2.tsx new file mode 100644 index 000000000..c20077d27 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/TableNameFilter2.tsx @@ -0,0 +1,41 @@ +import {MultiSelect, Paper} from "@mantine/core" + +const ALL_TABLES = [ + "nodes", + "document_versions", + "custom_fields", + "document_types", + "shared_nodes", + "tags", + "users", + "groups", + "users_roles", + "users_groups", + "roles_permissions", + "nodes_tags", + "document_types_custom_fields" +] as const + +interface Args { + tableNames?: string[] + onChange?: (value: string[]) => void +} + +export default function TableNameFilter({tableNames, onChange}: Args) { + return ( + e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + > + + + ) +} diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter2.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter2.tsx new file mode 100644 index 000000000..9e1a63957 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/TimestampFilter2.tsx @@ -0,0 +1,95 @@ +import type {TimestampFilterType} from "@/features/audit/types" +import {Button, Group, Paper, Stack} from "@mantine/core" +import {DateTimePicker} from "@mantine/dates" + +interface Args { + range?: { + from: string | null + to: string | null + } +} + +export default function TimestampFilter({range}: Args) { + const onChangeFrom = (value: string | null) => {} + + const onChangeTo = (value: string | null) => {} + + const handleQuickSelect = (type: "today" | "1hour" | "3hours") => { + const now = new Date() + let newRange: TimestampFilterType = {from: null, to: null} + + switch (type) { + case "today": + const startOfDay = new Date(now) + startOfDay.setHours(0, 0, 0, 0) + const endOfDay = new Date(now) + endOfDay.setHours(23, 59, 59, 999) + newRange = {from: startOfDay.toISOString(), to: endOfDay.toISOString()} + break + case "1hour": + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) + newRange = {from: oneHourAgo.toISOString(), to: now.toISOString()} + break + case "3hours": + const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000) + newRange = {from: threeHoursAgo.toISOString(), to: now.toISOString()} + break + } + } + + const handleClear = () => { + const newRange: TimestampFilterType = {from: null, to: null} + } + + return ( + + + + + + + + + + + + + + + + ) +} From e4c43388002148b610420b3d23325abbe1e94cad Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Wed, 27 Aug 2025 07:58:55 +0200 Subject: [PATCH 35/40] minor adjustments --- frontend/apps/ui/public/localization/de/_default.json | 3 ++- frontend/apps/ui/public/localization/en/_default.json | 3 ++- frontend/apps/ui/src/components/NavBar/NavBar.tsx | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/apps/ui/public/localization/de/_default.json b/frontend/apps/ui/public/localization/de/_default.json index 1d35ffed2..0a6e7e550 100644 --- a/frontend/apps/ui/public/localization/de/_default.json +++ b/frontend/apps/ui/public/localization/de/_default.json @@ -266,5 +266,6 @@ "notifications.role.updated.success": "Rolle wurde aktualisiert", "notifications.role.created.success": "Rolle wurde erstellt", "notifications.role.deleted.success": "Rolle wurde gelöscht", - "audit_log.name": "Audit-Protokolle" + "audit_log.name": "Audit-Protokolle", + "documents": "Dokumente" } diff --git a/frontend/apps/ui/public/localization/en/_default.json b/frontend/apps/ui/public/localization/en/_default.json index 4e7e0cc14..57446d295 100644 --- a/frontend/apps/ui/public/localization/en/_default.json +++ b/frontend/apps/ui/public/localization/en/_default.json @@ -266,5 +266,6 @@ "notifications.role.updated.success": "Role successfully updated", "notifications.role.created.success": "Role successfully created", "notifications.role.deleted.success": "Role successfully deleted", - "audit_log.name": "Audit Logs" + "audit_log.name": "Audit Logs", + "documents": "Documents" } diff --git a/frontend/apps/ui/src/components/NavBar/NavBar.tsx b/frontend/apps/ui/src/components/NavBar/NavBar.tsx index c071d2985..f44567695 100644 --- a/frontend/apps/ui/src/components/NavBar/NavBar.tsx +++ b/frontend/apps/ui/src/components/NavBar/NavBar.tsx @@ -27,7 +27,7 @@ import { import {Center, Group, Loader, Text} from "@mantine/core" import { IconAlignJustified, - IconCategory, + IconFile, IconHome, IconInbox, IconLogs, @@ -105,7 +105,7 @@ function NavBarFull() { )} {user.scopes.includes(NODE_VIEW) && ( - {NavLinkWithFeedback(t("by_document_type.name"), )} + {NavLinkWithFeedback(t("documents"), )} )} {user.scopes.includes(SHARED_NODE_VIEW) && ( @@ -213,7 +213,7 @@ function NavBarCollapsed() { )} {user.scopes.includes(NODE_VIEW) && ( - {NavLinkWithFeedbackShort()} + {NavLinkWithFeedbackShort()} )} {user.scopes.includes(SHARED_NODE_VIEW) && ( From 522f6d8361023e5d38f470d2e5b88c00b0ed0367 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Thu, 28 Aug 2025 08:21:40 +0200 Subject: [PATCH 36/40] refactoring auditLog --- .../ui/src/features/audit/components/List.tsx | 104 ++++----- .../src/features/audit/components/Search.tsx | 91 ++++---- .../audit/components/SearchContainer.tsx | 59 +++++ .../audit/components/useAuditLogTable.ts | 42 ++-- frontend/apps/ui/src/features/audit/types.ts | 4 +- frontend/apps/ui/src/features/ui/uiSlice.ts | 214 +++++++++++------- .../src/components/Table/TablePagination.tsx | 106 ++++----- 7 files changed, 362 insertions(+), 258 deletions(-) create mode 100644 frontend/apps/ui/src/features/audit/components/SearchContainer.tsx diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index eaf631926..050b20240 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -1,29 +1,27 @@ -import {useAppDispatch} from "@/app/hooks" -import {auditLogVisibleFilterUpdated} from "@/features/ui/uiSlice" import {useDynamicHeight} from "@/hooks/useDynamicHeight" import {Container, Group, ScrollArea, Stack} from "@mantine/core" import type {SortState} from "kommon" -import {useCallback, useRef} from "react" -import type {AuditLogItem, FilterListConfig, SortBy} from "../types" +import {useRef} from "react" +import type {AuditLogItem, SortBy} from "../types" import auditLogColumns from "./auditLogColumns" import Search from "./Search" import useAuditLogTable from "./useAuditLogTable" +import {useAppDispatch} from "@/app/hooks" +import {auditLogPaginationUpdated} from "@/features/ui/uiSlice" import {usePanelMode} from "@/hooks" import {ColumnSelector, DataTable, TablePagination, useTableData} from "kommon" export default function AuditLogsList() { - const auditLogTable = useAuditLogTable() const dispatch = useAppDispatch() - const mode = usePanelMode() + const {setSorting, isError, data, queryParams, error, isLoading, isFetching} = + useAuditLogTable() const actionButtonsRef = useRef(null) - const filtersRef = useRef(null) - const paginationRef = useRef(null) // Pagination // Table state management const {state, actions, visibleColumns} = useTableData({ - initialData: auditLogTable.data || { + initialData: data || { items: [], page_number: 1, page_size: 15, @@ -32,40 +30,35 @@ export default function AuditLogsList() { initialColumns: auditLogColumns }) - const remainingHeight = useDynamicHeight([ - actionButtonsRef, - filtersRef, - paginationRef - ]) + const remainingHeight = useDynamicHeight([actionButtonsRef]) // Handle sorting changes - const handleSortChange = useCallback( - (newSorting: SortState) => { - auditLogTable.setSorting( - newSorting.column as SortBy, - newSorting.direction - ) - }, - [auditLogTable] - ) + const handleSortChange = (newSorting: SortState) => { + setSorting(newSorting.column as SortBy, newSorting.direction) + } - const onFilterVisibilityChange = useCallback( - (items: FilterListConfig[]) => { - const visibleFilterKeys = items.filter(i => i.visible).map(i => i.key) + const handlePageSizeChange = (newValue: number) => { + dispatch( + auditLogPaginationUpdated({ + mode, + value: { + pageSize: newValue, + pageNumber: 1 + } + }) + ) + } - dispatch( - auditLogVisibleFilterUpdated({filterKeys: visibleFilterKeys, mode}) - ) - }, - [dispatch, mode] - ) + const handlePageNumberChange = (pageNumber: number) => { + dispatch(auditLogPaginationUpdated({mode, value: {pageNumber}})) + } - if (auditLogTable.isError) { + if (isError) { return (

Error loading audit logs

-

{auditLogTable.error?.toString() || "An error occurred"}

+

{error?.toString() || "An error occurred"}

) @@ -75,42 +68,37 @@ export default function AuditLogsList() { - + + + + - - ) } diff --git a/frontend/apps/ui/src/features/audit/components/Search.tsx b/frontend/apps/ui/src/features/audit/components/Search.tsx index 68828c811..a742c13c2 100644 --- a/frontend/apps/ui/src/features/audit/components/Search.tsx +++ b/frontend/apps/ui/src/features/audit/components/Search.tsx @@ -1,54 +1,57 @@ -import {ActionIcon, Button, Group, Menu, Stack, TextInput} from "@mantine/core" -import {IconAdjustmentsHorizontal, IconSearch} from "@tabler/icons-react" +import {useAppDispatch, useAppSelector} from "@/app/hooks" +import { + auditLogTableNameFilterValueCleared, + auditLogTableNameFilterValueUpdated, + selectAuditLogTableNameFilterValue +} from "@/features/ui/uiSlice" +import {usePanelMode} from "@/hooks" +import {useState} from "react" import OperationFilter from "./OperationFilter2" +import SearchContainer from "./SearchContainer" import TableNameFilter from "./TableNameFilter2" import TimestampFilter from "./TimestampFilter2" export default function Search() { - return ( - } - leftSectionPointerEvents="auto" - leftSection={ - - - - - - + const mode = usePanelMode() + const dispatch = useAppDispatch() + const tableNames = useAppSelector(s => + selectAuditLogTableNameFilterValue(s, mode) + ) + const [localTableNames, setLocalTableNames] = useState( + tableNames || [] + ) - - - - - - + const onLocalTableNamesChange = (values: string[]) => { + setLocalTableNames(values) + } - + const onSearch = () => { + dispatch( + auditLogTableNameFilterValueUpdated({ + mode, + value: localTableNames + }) + ) + } - - - - - - - } - placeholder="Search audit logs..." - /> + const onClear = () => { + setLocalTableNames([]) + + dispatch( + auditLogTableNameFilterValueCleared({ + mode + }) + ) + } + + return ( + + + + + ) } diff --git a/frontend/apps/ui/src/features/audit/components/SearchContainer.tsx b/frontend/apps/ui/src/features/audit/components/SearchContainer.tsx new file mode 100644 index 000000000..1b9402676 --- /dev/null +++ b/frontend/apps/ui/src/features/audit/components/SearchContainer.tsx @@ -0,0 +1,59 @@ +import {ActionIcon, Button, Group, Menu, Stack, TextInput} from "@mantine/core" +import {IconAdjustmentsHorizontal, IconSearch} from "@tabler/icons-react" + +interface Args { + children: React.ReactNode + onSearch?: () => void + onClear?: () => void +} + +export default function SearchContainer({children, onSearch, onClear}: Args) { + return ( + } + leftSectionPointerEvents="auto" + leftSection={ + + + + + + + + + + {children} + + + + + + + + + + + } + placeholder="Search audit logs..." + /> + ) +} diff --git a/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts index b277120ce..8b6cf77ba 100644 --- a/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts +++ b/frontend/apps/ui/src/features/audit/components/useAuditLogTable.ts @@ -1,5 +1,11 @@ +import {useAppSelector} from "@/app/hooks" +import { + selectAuditLogPageNumber, + selectAuditLogPageSize +} from "@/features/ui/uiSlice" +import {usePanelMode} from "@/hooks" import type {FilterValue} from "kommon" -import {useCallback, useMemo, useState} from "react" +import {useCallback, useMemo} from "react" import {useGetPaginatedAuditLogsQuery} from "../apiSlice" import type {AuditLogQueryParams} from "../types" @@ -12,12 +18,18 @@ type SortBy = | "user_id" | "id" +function useQueryParams(): AuditLogQueryParams { + const mode = usePanelMode() + const pageSize = useAppSelector(s => selectAuditLogPageSize(s, mode)) || 10 + const pageNumber = useAppSelector(s => selectAuditLogPageNumber(s, mode)) || 1 + const queryParams = {page_size: pageSize, page_number: pageNumber} + + return queryParams +} + // Enhanced helper hook with filter support export default function useAuditLogTable() { - const [queryParams, setQueryParams] = useState({ - page_number: 1, - page_size: 5 - }) + const queryParams = useQueryParams() // RTK Query const {data, isLoading, isFetching, isError, error} = @@ -90,33 +102,28 @@ export default function useAuditLogTable() { return filters }, [queryParams]) - // Helper functions - const setPage = useCallback((page_number: number) => { - setQueryParams(prev => ({...prev, page_number})) - }, []) - - const setPageSize = useCallback((page_size: number) => { - setQueryParams(prev => ({...prev, page_size, page_number: 1})) // Reset to first page - }, []) - const setSorting = useCallback( (sort_by: SortBy | null, sort_direction: "asc" | "desc" | null) => { + /* setQueryParams(prev => ({ ...prev, sort_by: sort_by || undefined, sort_direction: sort_direction || undefined, page_number: 1 // Reset to first page when sorting changes })) + */ }, [] ) const setFilters = useCallback((filters: Partial) => { + /* setQueryParams(prev => ({ ...prev, ...filters, page_number: 1 // Reset to first page when filters change })) + */ }, []) // Convert table filters to API format @@ -176,12 +183,14 @@ export default function useAuditLogTable() { ) const clearFilters = useCallback(() => { + /* setQueryParams(prev => ({ page_number: 1, page_size: prev.page_size, sort_by: prev.sort_by, sort_direction: prev.sort_direction })) + */ }, []) return { @@ -197,12 +206,9 @@ export default function useAuditLogTable() { currentFilters, // ← NEW: Filters in table format // Actions - setPage, - setPageSize, setSorting, setFilters, // API format setTableFilters, // Table format (NEW) - clearFilters, - setQueryParams // Direct access for advanced use + clearFilters } } diff --git a/frontend/apps/ui/src/features/audit/types.ts b/frontend/apps/ui/src/features/audit/types.ts index d0fe8947f..d6fd388dd 100644 --- a/frontend/apps/ui/src/features/audit/types.ts +++ b/frontend/apps/ui/src/features/audit/types.ts @@ -39,8 +39,8 @@ export interface FilterListConfig { export interface AuditLogQueryParams extends Partial { // Pagination (inherited from PaginatedArgs) - page_number?: number - page_size?: number + page_number: number + page_size: number // Sorting sort_by?: SortBy diff --git a/frontend/apps/ui/src/features/ui/uiSlice.ts b/frontend/apps/ui/src/features/ui/uiSlice.ts index e4c758563..9e893f6aa 100644 --- a/frontend/apps/ui/src/features/ui/uiSlice.ts +++ b/frontend/apps/ui/src/features/ui/uiSlice.ts @@ -222,6 +222,20 @@ interface LastInboxArg { type AuditLogFilterKey = "timestamp" | "operation" | "table_name" | "user" +interface Pagination { + pageNumber?: number + pageSize?: number +} + +interface AuditLogPanel { + timestampFilterValue?: TimestampFilterType + operationFilterValue?: Array + tableNameFilterValue?: Array + usernameFilterValue?: Array + pageNumber?: number + pageSize?: number +} + export interface UIState { uploader: UploaderState navbar: NavBarState @@ -284,22 +298,8 @@ export interface UIState { /* current page (number) in secondary viewer */ secondaryViewerCurrentPageNumber?: number viewerPageHaveChangedDialogVisibility?: DialogVisiblity - mainAuditLogVisibleFilters?: Array - mainAuditLogFiltersCollapse?: boolean - mainAuditLogTimestampFilterValue?: TimestampFilterType - mainAuditLogOperationFilterValue?: Array - mainAuditLogTableNameFilterValue?: Array - mainAuditLogUsernameFilterValue?: Array - mainAuditLogPageNumber?: number - mainAuditLogPageSize?: number - secondaryAuditLogVisibleFilters?: Array - secondaryAuditLogFiltersCollapse?: boolean - secondaryAuditLogTimestampFilterValue?: TimestampFilterType - secondaryAuditLogOperationFilterValue?: Array - secondaryAuditLogTableNameFilterValue?: Array - secondaryAuditLogUsernameFilterValue?: Array - secondaryAuditLogPageNumber?: number - secondaryAuditLogPageSize?: number + mainAuditLog?: AuditLogPanel + secondaryAuditLog?: AuditLogPanel } const initialState: UIState = { @@ -923,105 +923,173 @@ const uiSlice = createSlice({ const newVisibility = action.payload.visibility state.viewerPageHaveChangedDialogVisibility = newVisibility }, - auditLogVisibleFilterUpdated( + auditLogTimestampFilterValueUpdated( state, - action: PayloadAction<{mode: PanelMode; filterKeys: Array}> + action: PayloadAction<{mode: PanelMode; value: TimestampFilterType}> ) { - const {mode, filterKeys} = action.payload + const {mode, value} = action.payload if (mode == "main") { - state.mainAuditLogVisibleFilters = filterKeys + state.mainAuditLog = { + ...state.mainAuditLog, + timestampFilterValue: value + } return } - state.secondaryAuditLogVisibleFilters = filterKeys + state.secondaryAuditLog = { + ...state.secondaryAuditLog, + timestampFilterValue: value + } }, - - auditLogFiltersCollapseUpdated( + auditLogTimestampFilterValueCleared( state, - action: PayloadAction<{mode: PanelMode; value: boolean}> + action: PayloadAction<{mode: PanelMode}> ) { - const {mode, value} = action.payload + const {mode} = action.payload if (mode == "main") { - state.mainAuditLogFiltersCollapse = value + state.mainAuditLog = { + ...state.mainAuditLog, + timestampFilterValue: undefined + } return } - state.secondaryAuditLogFiltersCollapse = value + state.secondaryAuditLog = { + ...state.secondaryAuditLog, + timestampFilterValue: undefined + } }, - - auditLogTimestampFilterValueUpdated( + auditLogOperationFilterValueUpdated( state, - action: PayloadAction<{mode: PanelMode; value: TimestampFilterType}> + action: PayloadAction<{mode: PanelMode; value: Array}> ) { const {mode, value} = action.payload if (mode == "main") { - state.mainAuditLogTimestampFilterValue = value + state.mainAuditLog = { + ...state.mainAuditLog, + operationFilterValue: value + } return } - state.secondaryAuditLogTimestampFilterValue = value + state.secondaryAuditLog = { + ...state.secondaryAuditLog, + operationFilterValue: value + } }, - auditLogTimestampFilterValueCleared( + auditLogOperationFilterValueCleared( state, action: PayloadAction<{mode: PanelMode}> ) { const {mode} = action.payload if (mode == "main") { - state.mainAuditLogTimestampFilterValue = undefined + state.mainAuditLog = { + ...state.mainAuditLog, + operationFilterValue: undefined + } return } - state.secondaryAuditLogTimestampFilterValue = undefined + state.secondaryAuditLog = { + ...state.secondaryAuditLog, + operationFilterValue: undefined + } }, - auditLogOperationFilterValueUpdated( + auditLogTableNameFilterValueUpdated( state, - action: PayloadAction<{mode: PanelMode; value: Array}> + action: PayloadAction<{mode: PanelMode; value: Array}> ) { const {mode, value} = action.payload if (mode == "main") { - state.mainAuditLogOperationFilterValue = value + state.mainAuditLog = { + ...state.mainAuditLog, + tableNameFilterValue: value + } return } - state.secondaryAuditLogOperationFilterValue = value + state.secondaryAuditLog = { + ...state.secondaryAuditLog, + tableNameFilterValue: value + } }, - auditLogOperationFilterValueCleared( + auditLogTableNameFilterValueCleared( state, action: PayloadAction<{mode: PanelMode}> ) { const {mode} = action.payload if (mode == "main") { - state.mainAuditLogOperationFilterValue = undefined + state.mainAuditLog = { + ...state.mainAuditLog, + tableNameFilterValue: undefined + } return } - state.secondaryAuditLogOperationFilterValue = undefined + state.secondaryAuditLog = { + ...state.secondaryAuditLog, + tableNameFilterValue: undefined + } }, - - auditLogTableNameFilterValueUpdated( + auditLogPaginationUpdated( state, - action: PayloadAction<{mode: PanelMode; value: Array}> + action: PayloadAction<{mode: PanelMode; value: Pagination}> ) { const {mode, value} = action.payload + // initialize `newValue` with whatever is in current state + // i.e. depending on the `mode`, use value from `mainAuditLog` or from + // `secondaryAuditLog` + let newValue: Pagination = { + pageSize: + mode == "main" + ? state.mainAuditLog?.pageSize + : state.secondaryAuditLog?.pageSize, + pageNumber: + mode == "main" + ? state.mainAuditLog?.pageSize + : state.secondaryAuditLog?.pageSize + } + // if non empty value received as parameter - use it + // to update the state + if (value.pageNumber) { + newValue.pageNumber = value.pageNumber + } + + if (value.pageSize) { + newValue.pageSize = value.pageSize + } + if (mode == "main") { - state.mainAuditLogTableNameFilterValue = value + state.mainAuditLog = { + ...state.mainAuditLog, + ...newValue + } return } - state.secondaryAuditLogTableNameFilterValue = value + state.secondaryAuditLog = { + ...state.secondaryAuditLog, + ...newValue + } }, - auditLogTableNameFilterValueCleared( + auditLogPageNumberValueUpdated( state, - action: PayloadAction<{mode: PanelMode}> + action: PayloadAction<{mode: PanelMode; value: number}> ) { - const {mode} = action.payload + const {mode, value} = action.payload if (mode == "main") { - state.mainAuditLogTableNameFilterValue = undefined + state.mainAuditLog = { + ...state.mainAuditLog, + pageNumber: value + } return } - state.secondaryAuditLogTableNameFilterValue = undefined + state.secondaryAuditLog = { + ...state.secondaryAuditLog, + pageNumber: value + } } } }) @@ -1074,14 +1142,14 @@ export const { lastHomeUpdated, lastInboxUpdated, viewerPageHaveChangedDialogVisibilityChanged, - auditLogVisibleFilterUpdated, - auditLogFiltersCollapseUpdated, auditLogTimestampFilterValueUpdated, auditLogTimestampFilterValueCleared, auditLogOperationFilterValueUpdated, auditLogOperationFilterValueCleared, auditLogTableNameFilterValueCleared, - auditLogTableNameFilterValueUpdated + auditLogTableNameFilterValueUpdated, + auditLogPaginationUpdated, + auditLogPageNumberValueUpdated } = uiSlice.actions export default uiSlice.reducer @@ -1483,59 +1551,53 @@ export const selectViewerPagesHaveChangedDialogVisibility = ( return state.ui.viewerPageHaveChangedDialogVisibility || "closed" } -export const selectAuditLogVisibleFilters = ( +export const selectAuditLogTimestampFilterValue = ( state: RootState, mode: PanelMode ) => { if (mode == "main") { - return state.ui.mainAuditLogVisibleFilters + return state.ui.mainAuditLog?.timestampFilterValue } - return state.ui.secondaryAuditLogVisibleFilters + return state.ui.secondaryAuditLog?.timestampFilterValue } -export const selectAuditLogFiltersCollapse = ( +export const selectAuditLogOperationFilterValue = ( state: RootState, mode: PanelMode ) => { if (mode == "main") { - return state.ui.mainAuditLogFiltersCollapse + return state.ui.mainAuditLog?.operationFilterValue } - return state.ui.secondaryAuditLogFiltersCollapse + return state.ui.secondaryAuditLog?.operationFilterValue } -export const selectAuditLogTimestampFilterValue = ( +export const selectAuditLogTableNameFilterValue = ( state: RootState, mode: PanelMode ) => { if (mode == "main") { - return state.ui.mainAuditLogTimestampFilterValue + return state.ui.mainAuditLog?.tableNameFilterValue } - return state.ui.secondaryAuditLogTimestampFilterValue + return state.ui.secondaryAuditLog?.tableNameFilterValue } -export const selectAuditLogOperationFilterValue = ( - state: RootState, - mode: PanelMode -) => { +export const selectAuditLogPageSize = (state: RootState, mode: PanelMode) => { if (mode == "main") { - return state.ui.mainAuditLogOperationFilterValue + return state.ui.mainAuditLog?.pageSize } - return state.ui.secondaryAuditLogOperationFilterValue + return state.ui.secondaryAuditLog?.pageSize } -export const selectAuditLogTableNameFilterValue = ( - state: RootState, - mode: PanelMode -) => { +export const selectAuditLogPageNumber = (state: RootState, mode: PanelMode) => { if (mode == "main") { - return state.ui.mainAuditLogTableNameFilterValue + return state.ui.mainAuditLog?.pageNumber } - return state.ui.secondaryAuditLogTableNameFilterValue + return state.ui.secondaryAuditLog?.pageNumber } /* Load initial collapse state value from cookie */ diff --git a/frontend/packages/kommon/src/components/Table/TablePagination.tsx b/frontend/packages/kommon/src/components/Table/TablePagination.tsx index 8b54d0982..e982af276 100644 --- a/frontend/packages/kommon/src/components/Table/TablePagination.tsx +++ b/frontend/packages/kommon/src/components/Table/TablePagination.tsx @@ -1,77 +1,63 @@ // components/TablePagination/TablePagination.tsx import {Group, Pagination, Select, Text} from "@mantine/core" -import {forwardRef} from "react" -interface TablePaginationArgs { +interface Args { currentPage: number totalPages: number pageSize: number onPageChange: (page: number) => void onPageSizeChange: (size: number) => void pageSizeOptions?: number[] - showPageSizeSelector?: boolean totalItems?: number } -const TablePagination = forwardRef( - ( - { - currentPage, - totalPages, - pageSize, - onPageChange, - onPageSizeChange, - pageSizeOptions = [5, 10, 15, 25, 50, 100], - showPageSizeSelector = true, - totalItems - }, - ref - ) => { - const startItem = totalPages > 0 ? (currentPage - 1) * pageSize + 1 : 0 - const endItem = Math.min( - currentPage * pageSize, - totalItems || currentPage * pageSize - ) +export default function TablePagination({ + currentPage, + totalPages, + pageSize, + onPageChange, + onPageSizeChange, + pageSizeOptions = [5, 10, 15, 25, 50, 100], + totalItems +}: Args) { + const startItem = totalPages > 0 ? (currentPage - 1) * pageSize + 1 : 0 + const endItem = Math.min( + currentPage * pageSize, + totalItems || currentPage * pageSize + ) - return ( - - - {showPageSizeSelector && ( - - Show - ({ + value: String(size), + label: String(size) + }))} + value={String(pageSize)} + onChange={value => value && onPageSizeChange(Number(value))} + w={70} + /> - + {totalItems && ( + + {startItem} - {endItem} of {totalItems} + + )} - ) - } -) -export default TablePagination + + + ) +} From ac8b0f445d28486da2927d739a262b3abcf2719a Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Fri, 29 Aug 2025 06:55:31 +0200 Subject: [PATCH 37/40] operations filter and table names filter works pretty well --- .../ui/src/features/audit/components/List.tsx | 16 +- .../audit/components/OperationFilter2.tsx | 3 +- .../src/features/audit/components/Search.tsx | 34 +++- .../audit/components/SearchContainer.tsx | 1 + .../audit/components/useAuditLogTable.ts | 22 ++- frontend/apps/ui/src/features/ui/uiSlice.ts | 153 ++++++------------ .../kommon/src/components/Table/DataTable.tsx | 2 +- .../kommon/src/components/Table/types.ts | 6 +- frontend/packages/kommon/src/index.tsx | 2 + 9 files changed, 116 insertions(+), 123 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/List.tsx b/frontend/apps/ui/src/features/audit/components/List.tsx index 050b20240..ea90fed68 100644 --- a/frontend/apps/ui/src/features/audit/components/List.tsx +++ b/frontend/apps/ui/src/features/audit/components/List.tsx @@ -2,20 +2,23 @@ import {useDynamicHeight} from "@/hooks/useDynamicHeight" import {Container, Group, ScrollArea, Stack} from "@mantine/core" import type {SortState} from "kommon" import {useRef} from "react" -import type {AuditLogItem, SortBy} from "../types" +import type {AuditLogItem} from "../types" import auditLogColumns from "./auditLogColumns" import Search from "./Search" import useAuditLogTable from "./useAuditLogTable" import {useAppDispatch} from "@/app/hooks" -import {auditLogPaginationUpdated} from "@/features/ui/uiSlice" +import { + auditLogPaginationUpdated, + auditLogSortingUpdated +} from "@/features/ui/uiSlice" import {usePanelMode} from "@/hooks" import {ColumnSelector, DataTable, TablePagination, useTableData} from "kommon" export default function AuditLogsList() { const dispatch = useAppDispatch() const mode = usePanelMode() - const {setSorting, isError, data, queryParams, error, isLoading, isFetching} = + const {isError, data, queryParams, error, isLoading, isFetching} = useAuditLogTable() const actionButtonsRef = useRef(null) @@ -32,9 +35,8 @@ export default function AuditLogsList() { const remainingHeight = useDynamicHeight([actionButtonsRef]) - // Handle sorting changes - const handleSortChange = (newSorting: SortState) => { - setSorting(newSorting.column as SortBy, newSorting.direction) + const handleSortChange = (value: SortState) => { + dispatch(auditLogSortingUpdated({mode, value})) } const handlePageSizeChange = (newValue: number) => { @@ -89,7 +91,7 @@ export default function AuditLogsList() { data={data?.items || []} columns={visibleColumns} sorting={{ - column: queryParams.sort_by || null, + column: queryParams.sort_by, direction: queryParams.sort_direction || null }} onSortChange={handleSortChange} diff --git a/frontend/apps/ui/src/features/audit/components/OperationFilter2.tsx b/frontend/apps/ui/src/features/audit/components/OperationFilter2.tsx index f3d7aec07..d8bda6faa 100644 --- a/frontend/apps/ui/src/features/audit/components/OperationFilter2.tsx +++ b/frontend/apps/ui/src/features/audit/components/OperationFilter2.tsx @@ -1,7 +1,8 @@ import {MultiSelect, Paper} from "@mantine/core" +import {AuditOperation} from "../types" interface Args { - operations?: string[] + operations?: AuditOperation[] onChange?: (value: string[]) => void } diff --git a/frontend/apps/ui/src/features/audit/components/Search.tsx b/frontend/apps/ui/src/features/audit/components/Search.tsx index a742c13c2..6c0f9efa3 100644 --- a/frontend/apps/ui/src/features/audit/components/Search.tsx +++ b/frontend/apps/ui/src/features/audit/components/Search.tsx @@ -1,7 +1,8 @@ import {useAppDispatch, useAppSelector} from "@/app/hooks" +import type {AuditOperation} from "@/features/audit/types" import { - auditLogTableNameFilterValueCleared, - auditLogTableNameFilterValueUpdated, + auditLogTableFiltersUpdated, + selectAuditLogOperationFilterValue, selectAuditLogTableNameFilterValue } from "@/features/ui/uiSlice" import {usePanelMode} from "@/hooks" @@ -17,29 +18,45 @@ export default function Search() { const tableNames = useAppSelector(s => selectAuditLogTableNameFilterValue(s, mode) ) + const operations = useAppSelector(s => + selectAuditLogOperationFilterValue(s, mode) + ) const [localTableNames, setLocalTableNames] = useState( tableNames || [] ) + const [localOperations, setLocalOperations] = useState( + operations || [] + ) const onLocalTableNamesChange = (values: string[]) => { setLocalTableNames(values) } + const onLocalOperationChange = (values: string[]) => { + setLocalOperations(values as AuditOperation[]) + } + const onSearch = () => { dispatch( - auditLogTableNameFilterValueUpdated({ + auditLogTableFiltersUpdated({ mode, - value: localTableNames + tableNameFilterValue: localTableNames, + operationFilterValue: localOperations, + timestampFilterValue: undefined }) ) } const onClear = () => { setLocalTableNames([]) + setLocalOperations([]) dispatch( - auditLogTableNameFilterValueCleared({ - mode + auditLogTableFiltersUpdated({ + mode, + tableNameFilterValue: undefined, + operationFilterValue: undefined, + timestampFilterValue: undefined }) ) } @@ -51,7 +68,10 @@ export default function Search() { tableNames={localTableNames} onChange={onLocalTableNamesChange} /> - + ) } diff --git a/frontend/apps/ui/src/features/audit/components/SearchContainer.tsx b/frontend/apps/ui/src/features/audit/components/SearchContainer.tsx index 1b9402676..dd8cd0674 100644 --- a/frontend/apps/ui/src/features/audit/components/SearchContainer.tsx +++ b/frontend/apps/ui/src/features/audit/components/SearchContainer.tsx @@ -19,6 +19,7 @@ export default function SearchContainer({children, onSearch, onClear}: Args) { position="bottom-start" closeOnItemClick={false} transitionProps={{duration: 0}} + onClose={onSearch} > + selectAuditLogTableNameFilterValue(s, mode) + ) + const operations = useAppSelector(s => + selectAuditLogOperationFilterValue(s, mode) + ) const pageSize = useAppSelector(s => selectAuditLogPageSize(s, mode)) || 10 const pageNumber = useAppSelector(s => selectAuditLogPageNumber(s, mode)) || 1 - const queryParams = {page_size: pageSize, page_number: pageNumber} + const sorting = useAppSelector(s => selectAuditLogSorting(s, mode)) + const column = sorting?.column as SortBy | undefined + const queryParams: AuditLogQueryParams = { + page_size: pageSize, + page_number: pageNumber, + sort_by: column, + sort_direction: sorting?.direction || undefined, + filter_table_name: tableNames?.join(","), + filter_operation: operations?.join(",") + } return queryParams } diff --git a/frontend/apps/ui/src/features/ui/uiSlice.ts b/frontend/apps/ui/src/features/ui/uiSlice.ts index 9e893f6aa..eb173403c 100644 --- a/frontend/apps/ui/src/features/ui/uiSlice.ts +++ b/frontend/apps/ui/src/features/ui/uiSlice.ts @@ -29,6 +29,7 @@ import type { import type {AuditOperation, TimestampFilterType} from "@/features/audit/types" import type {CategoryColumn} from "@/features/nodes/components/Commander/DocumentsByTypeCommander/types" import {DialogVisiblity} from "@/types.d/common" +import {SortState} from "kommon" const COLLAPSED_WIDTH = 55 const FULL_WIDTH = 200 @@ -234,6 +235,7 @@ interface AuditLogPanel { usernameFilterValue?: Array pageNumber?: number pageSize?: number + sorting?: SortState } export interface UIState { @@ -923,113 +925,36 @@ const uiSlice = createSlice({ const newVisibility = action.payload.visibility state.viewerPageHaveChangedDialogVisibility = newVisibility }, - auditLogTimestampFilterValueUpdated( + auditLogTableFiltersUpdated( state, - action: PayloadAction<{mode: PanelMode; value: TimestampFilterType}> + action: PayloadAction<{ + mode: PanelMode + timestampFilterValue?: TimestampFilterType + tableNameFilterValue?: Array + operationFilterValue?: Array + }> ) { - const {mode, value} = action.payload - if (mode == "main") { - state.mainAuditLog = { - ...state.mainAuditLog, - timestampFilterValue: value - } - return - } - - state.secondaryAuditLog = { - ...state.secondaryAuditLog, - timestampFilterValue: value - } - }, - auditLogTimestampFilterValueCleared( - state, - action: PayloadAction<{mode: PanelMode}> - ) { - const {mode} = action.payload - if (mode == "main") { - state.mainAuditLog = { - ...state.mainAuditLog, - timestampFilterValue: undefined - } - return - } - - state.secondaryAuditLog = { - ...state.secondaryAuditLog, - timestampFilterValue: undefined - } - }, - auditLogOperationFilterValueUpdated( - state, - action: PayloadAction<{mode: PanelMode; value: Array}> - ) { - const {mode, value} = action.payload - if (mode == "main") { - state.mainAuditLog = { - ...state.mainAuditLog, - operationFilterValue: value - } - return - } - - state.secondaryAuditLog = { - ...state.secondaryAuditLog, - operationFilterValue: value - } - }, - auditLogOperationFilterValueCleared( - state, - action: PayloadAction<{mode: PanelMode}> - ) { - const {mode} = action.payload - if (mode == "main") { - state.mainAuditLog = { - ...state.mainAuditLog, - operationFilterValue: undefined - } - return - } - - state.secondaryAuditLog = { - ...state.secondaryAuditLog, - operationFilterValue: undefined - } - }, - - auditLogTableNameFilterValueUpdated( - state, - action: PayloadAction<{mode: PanelMode; value: Array}> - ) { - const {mode, value} = action.payload - if (mode == "main") { - state.mainAuditLog = { - ...state.mainAuditLog, - tableNameFilterValue: value - } - return - } - - state.secondaryAuditLog = { - ...state.secondaryAuditLog, - tableNameFilterValue: value - } - }, - auditLogTableNameFilterValueCleared( - state, - action: PayloadAction<{mode: PanelMode}> - ) { - const {mode} = action.payload + const { + mode, + tableNameFilterValue, + operationFilterValue, + timestampFilterValue + } = action.payload if (mode == "main") { state.mainAuditLog = { ...state.mainAuditLog, - tableNameFilterValue: undefined + tableNameFilterValue, + operationFilterValue, + timestampFilterValue } return } state.secondaryAuditLog = { ...state.secondaryAuditLog, - tableNameFilterValue: undefined + tableNameFilterValue, + operationFilterValue, + timestampFilterValue } }, auditLogPaginationUpdated( @@ -1090,6 +1015,24 @@ const uiSlice = createSlice({ ...state.secondaryAuditLog, pageNumber: value } + }, + auditLogSortingUpdated( + state, + action: PayloadAction<{mode: PanelMode; value: SortState}> + ) { + const {mode, value} = action.payload + if (mode == "main") { + state.mainAuditLog = { + ...state.mainAuditLog, + sorting: value + } + return + } + + state.secondaryAuditLog = { + ...state.secondaryAuditLog, + sorting: value + } } } }) @@ -1142,14 +1085,10 @@ export const { lastHomeUpdated, lastInboxUpdated, viewerPageHaveChangedDialogVisibilityChanged, - auditLogTimestampFilterValueUpdated, - auditLogTimestampFilterValueCleared, - auditLogOperationFilterValueUpdated, - auditLogOperationFilterValueCleared, - auditLogTableNameFilterValueCleared, - auditLogTableNameFilterValueUpdated, + auditLogTableFiltersUpdated, auditLogPaginationUpdated, - auditLogPageNumberValueUpdated + auditLogPageNumberValueUpdated, + auditLogSortingUpdated } = uiSlice.actions export default uiSlice.reducer @@ -1600,6 +1539,14 @@ export const selectAuditLogPageNumber = (state: RootState, mode: PanelMode) => { return state.ui.secondaryAuditLog?.pageNumber } +export const selectAuditLogSorting = (state: RootState, mode: PanelMode) => { + if (mode == "main") { + return state.ui.mainAuditLog?.sorting + } + + return state.ui.secondaryAuditLog?.sorting +} + /* Load initial collapse state value from cookie */ function initial_collapse_value(): boolean { const collapsed = Cookies.get(NAVBAR_COLLAPSED_COOKIE) as BooleanString diff --git a/frontend/packages/kommon/src/components/Table/DataTable.tsx b/frontend/packages/kommon/src/components/Table/DataTable.tsx index 880c2676a..fa611d6fe 100644 --- a/frontend/packages/kommon/src/components/Table/DataTable.tsx +++ b/frontend/packages/kommon/src/components/Table/DataTable.tsx @@ -79,7 +79,7 @@ export default function DataTable({ if (sorting.direction === "asc") { newDirection = "desc" } else if (sorting.direction === "desc") { - newDirection = null + newDirection = "asc" } } diff --git a/frontend/packages/kommon/src/components/Table/types.ts b/frontend/packages/kommon/src/components/Table/types.ts index de1848d15..596f5f7f2 100644 --- a/frontend/packages/kommon/src/components/Table/types.ts +++ b/frontend/packages/kommon/src/components/Table/types.ts @@ -5,9 +5,11 @@ export interface PaginatedResponse { items: T[] } +export type SortDirection = "asc" | "desc" + export interface SortState { - column: string | null - direction: "asc" | "desc" | null + column?: string | null + direction?: SortDirection | null } export interface FilterValue { diff --git a/frontend/packages/kommon/src/index.tsx b/frontend/packages/kommon/src/index.tsx index 3c9fc298c..7eb68d653 100644 --- a/frontend/packages/kommon/src/index.tsx +++ b/frontend/packages/kommon/src/index.tsx @@ -21,6 +21,7 @@ import type { ColumnConfig, FilterValue, PaginatedResponse, + SortDirection, SortState } from "./components/Table/types" @@ -44,5 +45,6 @@ export type { I18NPermissionTree, I18NRoleFormModal, PaginatedResponse, + SortDirection, SortState } From 9aae67c0b4975b5f4cd8daaced032e0cbad79562 Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Fri, 29 Aug 2025 07:42:21 +0200 Subject: [PATCH 38/40] 3 filters work! --- .../src/features/audit/components/Search.tsx | 11 +- .../audit/components/TimestampFilter2.tsx | 43 +++- .../audit/components/useAuditLogTable.ts | 185 +----------------- 3 files changed, 53 insertions(+), 186 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/Search.tsx b/frontend/apps/ui/src/features/audit/components/Search.tsx index 6c0f9efa3..dc05cfe8d 100644 --- a/frontend/apps/ui/src/features/audit/components/Search.tsx +++ b/frontend/apps/ui/src/features/audit/components/Search.tsx @@ -1,5 +1,5 @@ import {useAppDispatch, useAppSelector} from "@/app/hooks" -import type {AuditOperation} from "@/features/audit/types" +import type {AuditOperation, TimestampFilterType} from "@/features/audit/types" import { auditLogTableFiltersUpdated, selectAuditLogOperationFilterValue, @@ -27,6 +27,7 @@ export default function Search() { const [localOperations, setLocalOperations] = useState( operations || [] ) + const [localRange, setLocalRange] = useState() const onLocalTableNamesChange = (values: string[]) => { setLocalTableNames(values) @@ -36,13 +37,17 @@ export default function Search() { setLocalOperations(values as AuditOperation[]) } + const onLocalRangeChange = (value: TimestampFilterType) => { + setLocalRange(value) + } + const onSearch = () => { dispatch( auditLogTableFiltersUpdated({ mode, tableNameFilterValue: localTableNames, operationFilterValue: localOperations, - timestampFilterValue: undefined + timestampFilterValue: localRange }) ) } @@ -63,7 +68,7 @@ export default function Search() { return ( - + void } -export default function TimestampFilter({range}: Args) { - const onChangeFrom = (value: string | null) => {} +export default function TimestampFilter({range, onChange}: Args) { + const onChangeFrom = (valueFrom: string | null) => { + if (onChange) { + onChange({ + from: valueFrom, + to: range?.to || null + }) + } + } - const onChangeTo = (value: string | null) => {} + const onChangeTo = (valueTo: string | null) => { + if (onChange) { + onChange({ + to: valueTo, + from: range?.from || null + }) + } + } const handleQuickSelect = (type: "today" | "1hour" | "3hours") => { const now = new Date() @@ -25,24 +37,39 @@ export default function TimestampFilter({range}: Args) { const endOfDay = new Date(now) endOfDay.setHours(23, 59, 59, 999) newRange = {from: startOfDay.toISOString(), to: endOfDay.toISOString()} + if (onChange) { + onChange(newRange) + } break case "1hour": const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) newRange = {from: oneHourAgo.toISOString(), to: now.toISOString()} + if (onChange) { + onChange(newRange) + } break case "3hours": const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000) newRange = {from: threeHoursAgo.toISOString(), to: now.toISOString()} + if (onChange) { + onChange(newRange) + } break } } const handleClear = () => { const newRange: TimestampFilterType = {from: null, to: null} + if (onChange) { + onChange(newRange) + } } return ( - + e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + > selectAuditLogOperationFilterValue(s, mode) ) + const timestamp = useAppSelector(s => + selectAuditLogTimestampFilterValue(s, mode) + ) const pageSize = useAppSelector(s => selectAuditLogPageSize(s, mode)) || 10 const pageNumber = useAppSelector(s => selectAuditLogPageNumber(s, mode)) || 1 const sorting = useAppSelector(s => selectAuditLogSorting(s, mode)) const column = sorting?.column as SortBy | undefined + const queryParams: AuditLogQueryParams = { page_size: pageSize, page_number: pageNumber, sort_by: column, sort_direction: sorting?.direction || undefined, filter_table_name: tableNames?.join(","), - filter_operation: operations?.join(",") + filter_operation: operations?.join(","), + filter_timestamp_from: timestamp?.from || undefined, + filter_timestamp_to: timestamp?.to || undefined } return queryParams } -// Enhanced helper hook with filter support export default function useAuditLogTable() { const queryParams = useQueryParams() - // RTK Query const {data, isLoading, isFetching, isError, error} = useGetPaginatedAuditLogsQuery(queryParams) - // Convert API params to table filters format - const currentFilters = useMemo((): FilterValue[] => { - const filters: FilterValue[] = [] - if (queryParams.filter_operation) { - filters.push({ - column: "operation", - value: queryParams.filter_operation, - operator: "equals" - }) - } - - if (queryParams.filter_table_name) { - filters.push({ - column: "table_name", - value: queryParams.filter_table_name, - operator: "equals" - }) - } - - if (queryParams.filter_username) { - filters.push({ - column: "username", - value: queryParams.filter_username, - operator: "contains" - }) - } - - if (queryParams.filter_user_id) { - filters.push({ - column: "user_id", - value: queryParams.filter_user_id, - operator: "equals" - }) - } - - if (queryParams.filter_record_id) { - filters.push({ - column: "record_id", - value: queryParams.filter_record_id, - operator: "contains" - }) - } - - // Handle timestamp filters - if (queryParams.filter_timestamp_from && queryParams.filter_timestamp_to) { - filters.push({ - column: "timestamp", - value: `${queryParams.filter_timestamp_from} - ${queryParams.filter_timestamp_to}`, - operator: "range" - }) - } else if (queryParams.filter_timestamp_from) { - filters.push({ - column: "timestamp", - value: queryParams.filter_timestamp_from, - operator: "from" - }) - } else if (queryParams.filter_timestamp_to) { - filters.push({ - column: "timestamp", - value: queryParams.filter_timestamp_to, - operator: "to" - }) - } - - return filters - }, [queryParams]) - - const setSorting = useCallback( - (sort_by: SortBy | null, sort_direction: "asc" | "desc" | null) => { - /* - setQueryParams(prev => ({ - ...prev, - sort_by: sort_by || undefined, - sort_direction: sort_direction || undefined, - page_number: 1 // Reset to first page when sorting changes - })) - */ - }, - [] - ) - - const setFilters = useCallback((filters: Partial) => { - /* - setQueryParams(prev => ({ - ...prev, - ...filters, - page_number: 1 // Reset to first page when filters change - })) - */ - }, []) - - // Convert table filters to API format - const setTableFilters = useCallback( - (newFilters: FilterValue[]) => { - const apiFilters: Partial = { - // Clear existing filters - filter_operation: undefined, - filter_table_name: undefined, - filter_username: undefined, - filter_user_id: undefined, - filter_record_id: undefined, - filter_timestamp_from: undefined, - filter_timestamp_to: undefined - } - - newFilters.forEach(filter => { - const value = Array.isArray(filter.value) - ? filter.value[0] - : filter.value - - switch (filter.column) { - case "operation": - apiFilters.filter_operation = value as - | "INSERT" - | "UPDATE" - | "DELETE" - break - case "table_name": - apiFilters.filter_table_name = value - break - case "username": - apiFilters.filter_username = value - break - case "user_id": - apiFilters.filter_user_id = value - break - case "record_id": - apiFilters.filter_record_id = value - break - case "timestamp": - if (filter.operator === "range" && typeof value === "string") { - const [from, to] = value.split(" - ") - if (from) apiFilters.filter_timestamp_from = from.trim() - if (to) apiFilters.filter_timestamp_to = to.trim() - } else if (filter.operator === "from") { - apiFilters.filter_timestamp_from = value - } else if (filter.operator === "to") { - apiFilters.filter_timestamp_to = value - } - break - } - }) - setFilters(apiFilters) - }, - [setFilters] - ) - - const clearFilters = useCallback(() => { - /* - setQueryParams(prev => ({ - page_number: 1, - page_size: prev.page_size, - sort_by: prev.sort_by, - sort_direction: prev.sort_direction - })) - */ - }, []) - return { - // Data data, isLoading, isFetching, isError, error, - - // Current state - queryParams, - currentFilters, // ← NEW: Filters in table format - - // Actions - setSorting, - setFilters, // API format - setTableFilters, // Table format (NEW) - clearFilters + queryParams } } From 8ba1fa49753d77b763cb38f79e2a99b5c5bb5c7b Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Fri, 29 Aug 2025 08:55:40 +0200 Subject: [PATCH 39/40] 3 filters work! --- .../src/features/audit/components/Search.tsx | 1 + .../sql/audit_trigger_association_table_up.sql | 13 ++++--------- .../core/alembic/sql/audit_trigger_upgrade.sql | 16 ++++++++++------ .../core/features/audit/db/audit_context.py | 16 +++++++--------- papermerge/core/features/nodes/db/api.py | 5 +++-- papermerge/core/features/nodes/router.py | 18 +++++++++++++++--- 6 files changed, 40 insertions(+), 29 deletions(-) diff --git a/frontend/apps/ui/src/features/audit/components/Search.tsx b/frontend/apps/ui/src/features/audit/components/Search.tsx index dc05cfe8d..50f71839d 100644 --- a/frontend/apps/ui/src/features/audit/components/Search.tsx +++ b/frontend/apps/ui/src/features/audit/components/Search.tsx @@ -55,6 +55,7 @@ export default function Search() { const onClear = () => { setLocalTableNames([]) setLocalOperations([]) + setLocalRange({from: null, to: null}) dispatch( auditLogTableFiltersUpdated({ diff --git a/papermerge/core/alembic/sql/audit_trigger_association_table_up.sql b/papermerge/core/alembic/sql/audit_trigger_association_table_up.sql index 6a88989a9..c43896273 100644 --- a/papermerge/core/alembic/sql/audit_trigger_association_table_up.sql +++ b/papermerge/core/alembic/sql/audit_trigger_association_table_up.sql @@ -119,15 +119,10 @@ BEGIN audit_row.new_values = CASE WHEN TG_OP != 'DELETE' THEN row_to_json(NEW)::jsonb END; audit_row.audit_message = audit_message; -- Human-readable message with IDs - -- Get application context - BEGIN - audit_row.user_id = nullif(current_setting('app.user_id', true), '')::uuid; - audit_row.username = nullif(current_setting('app.username', true), ''); - audit_row.session_id = nullif(current_setting('app.session_id', true), ''); - audit_row.reason = nullif(current_setting('app.reason', true), ''); - EXCEPTION WHEN OTHERS THEN - -- Continue if context retrieval fails - END; + audit_row.user_id = current_setting('app.user_id', false); + audit_row.username = current_setting('app.username', false); + audit_row.session_id = nullif(current_setting('app.session_id', true), ''); + audit_row.reason = nullif(current_setting('app.reason', true), ''); INSERT INTO audit_log VALUES (audit_row.*); diff --git a/papermerge/core/alembic/sql/audit_trigger_upgrade.sql b/papermerge/core/alembic/sql/audit_trigger_upgrade.sql index 73f20422c..466588e32 100644 --- a/papermerge/core/alembic/sql/audit_trigger_upgrade.sql +++ b/papermerge/core/alembic/sql/audit_trigger_upgrade.sql @@ -128,19 +128,23 @@ CREATE TRIGGER audit_groups_trigger -- Helper function to set application context CREATE OR REPLACE FUNCTION set_audit_context( - p_user_id uuid DEFAULT NULL, - p_username text DEFAULT NULL, + p_user_id uuid, + p_username text, p_session_id text DEFAULT NULL, p_reason text DEFAULT NULL ) RETURNS void AS $$ BEGIN - IF p_user_id IS NOT NULL THEN - PERFORM set_config('app.user_id', p_user_id::text, false); + IF p_user_id IS NULL THEN + RAISE EXCEPTION 'p_user_id cannot be NULL'; END IF; - IF p_username IS NOT NULL THEN - PERFORM set_config('app.username', p_username, false); + + IF p_username IS NULL OR trim(p_username) = '' THEN + RAISE EXCEPTION 'p_username cannot be NULL or empty'; END IF; + + PERFORM set_config('app.user_id', p_user_id::text, false); + PERFORM set_config('app.username', p_username, false); IF p_session_id IS NOT NULL THEN PERFORM set_config('app.session_id', p_session_id, false); END IF; diff --git a/papermerge/core/features/audit/db/audit_context.py b/papermerge/core/features/audit/db/audit_context.py index 6a6f6b6fd..c89a5f91d 100644 --- a/papermerge/core/features/audit/db/audit_context.py +++ b/papermerge/core/features/audit/db/audit_context.py @@ -15,12 +15,12 @@ class AsyncAuditContext: captured by PostgreSQL triggers. """ def __init__( - self, - session: AsyncSession, - user_id: Optional[UUID] = None, - username: Optional[str] = None, - session_id: Optional[str] = None, - reason: Optional[str] = None, + self, + session: AsyncSession, + user_id: UUID, + username:str, + session_id: Optional[str] = None, + reason: Optional[str] = None, ): self.session = session self.user_id = user_id @@ -39,16 +39,14 @@ async def __aenter__(self): :reason ) """ - try: await self.session.execute(text(sql), { - 'user_id': str(self.user_id) if self.user_id else None, + 'user_id': self.user_id, 'username': self.username, 'session_id': self.session_id, 'reason': self.reason }) self._context_set = True - logger.debug(f"Set audit context for user {self.username} ({self.user_id})") except SQLAlchemyError as e: logger.warning(f"Failed to set audit context: {e}") self._context_set = False diff --git a/papermerge/core/features/nodes/db/api.py b/papermerge/core/features/nodes/db/api.py index 48db763a7..974b9c102 100644 --- a/papermerge/core/features/nodes/db/api.py +++ b/papermerge/core/features/nodes/db/api.py @@ -248,14 +248,15 @@ async def assign_node_tags( if name not in existing_db_tags_names ] db_session.add_all(new_db_tags) - await db_session.commit() + await db_session.flush() + db_tags = (await db_session.execute( select(orm.Tag).where(orm.Tag.name.in_(tags)) )).scalars() try: node.tags = db_tags.all() - await db_session.commit() + await db_session.flush() except Exception as e: error = schema.Error(messages=[str(e)]) return None, error diff --git a/papermerge/core/features/nodes/router.py b/papermerge/core/features/nodes/router.py index ad6f4d68d..c0b5c703d 100644 --- a/papermerge/core/features/nodes/router.py +++ b/papermerge/core/features/nodes/router.py @@ -417,9 +417,21 @@ async def assign_node_tags( ): raise exc.HTTP403Forbidden() - node, error = await nodes_dbapi.assign_node_tags( - db_session, node_id=node_id, tags=tags, user_id=user.id - ) + async with AsyncAuditContext( + db_session, + user_id=user.id, + username=user.username + ): + node, error = await nodes_dbapi.assign_node_tags( + db_session, node_id=node_id, tags=tags, user_id=user.id + ) + if error: + await db_session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Failed to assign tags: {error}" + ) + await db_session.commit() # Single commit with audit context active except EntityNotFound: raise HTTP404NotFound From 53103629a46c0030c228dc24f3a68796ec9faa4f Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 30 Aug 2025 05:57:28 +0200 Subject: [PATCH 40/40] OKish audit log --- .../audit/components/OperationFilter.tsx | 81 ------- .../audit/components/TableNameFilter.tsx | 104 --------- .../audit/components/TimestampFilter.tsx | 220 ------------------ 3 files changed, 405 deletions(-) delete mode 100644 frontend/apps/ui/src/features/audit/components/OperationFilter.tsx delete mode 100644 frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx delete mode 100644 frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx diff --git a/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx b/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx deleted file mode 100644 index b00e786cc..000000000 --- a/frontend/apps/ui/src/features/audit/components/OperationFilter.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import {useAppDispatch, useAppSelector} from "@/app/hooks" -import type { - AuditLogQueryParams, - AuditOperation, - FilterHookReturn -} from "@/features/audit/types" -import { - auditLogOperationFilterValueUpdated, - selectAuditLogOperationFilterValue -} from "@/features/ui/uiSlice" -import {usePanelMode} from "@/hooks" -import {MultiSelect, Paper} from "@mantine/core" -import React, {useEffect} from "react" - -interface Args { - setQueryParams: React.Dispatch> -} - -export function useOperationFilter({setQueryParams}: Args): FilterHookReturn { - const mode = usePanelMode() - const operations = useAppSelector(s => - selectAuditLogOperationFilterValue(s, mode) - ) - - const clear = () => { - setQueryParams(prev => ({ - ...prev, - filter_operation: undefined - })) - } - - useEffect(() => { - setQueryParams(prev => ({ - ...prev, - filter_operation: operations?.join(",") - })) - }, []) - - return {clear} -} - -export default function OperationFilter({setQueryParams}: Args) { - const mode = usePanelMode() - const dispatch = useAppDispatch() - const operations = useAppSelector(s => - selectAuditLogOperationFilterValue(s, mode) - ) - - useEffect(() => { - setQueryParams(prev => ({ - ...prev, - filter_operation: operations?.join(",") - })) - }, [operations]) - - const onChange = (values: string[]) => { - setQueryParams(prev => ({ - ...prev, - filter_operation: values.join(",") - })) - dispatch( - auditLogOperationFilterValueUpdated({ - mode, - value: values as Array - }) - ) - } - - return ( - - - - ) -} diff --git a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx b/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx deleted file mode 100644 index b1f96a36d..000000000 --- a/frontend/apps/ui/src/features/audit/components/TableNameFilter.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import {useAppDispatch, useAppSelector} from "@/app/hooks" -import { - auditLogTableNameFilterValueUpdated, - selectAuditLogTableNameFilterValue -} from "@/features/ui/uiSlice" -import {usePanelMode} from "@/hooks" -import {MultiSelect, Paper} from "@mantine/core" -import React, {useCallback, useEffect, useMemo} from "react" -import type {AuditLogQueryParams, FilterHookReturn} from "../types" - -interface Args { - setQueryParams: React.Dispatch> -} - -// Move static data outside component to prevent recreation -const ALL_TABLES = [ - "nodes", - "document_versions", - "custom_fields", - "document_types", - "shared_nodes", - "tags", - "users", - "groups", - "users_roles", - "users_groups", - "roles_permissions", - "nodes_tags", - "document_types_custom_fields" -] as const - -export function useTableNameFilter({setQueryParams}: Args): FilterHookReturn { - const mode = usePanelMode() - const table_names = useAppSelector(s => - selectAuditLogTableNameFilterValue(s, mode) - ) - - // Memoize the clear function to prevent unnecessary re-renders - const clear = useCallback(() => { - setQueryParams(prev => ({ - ...prev, - filter_table_name: undefined - })) - }, [setQueryParams]) - - useEffect(() => { - setQueryParams(prev => ({ - ...prev, - filter_table_name: table_names?.join(",") - })) - }, [setQueryParams, table_names]) // Add dependencies - - return {clear} -} - -// Memoize the main component -const TableNameFilter = React.memo(function TableNameFilter({ - setQueryParams -}: Args) { - const mode = usePanelMode() - const dispatch = useAppDispatch() - const table_names = useAppSelector(s => - selectAuditLogTableNameFilterValue(s, mode) - ) - - // Memoize the onChange handler - const onChange = useCallback( - (values: string[]) => { - const joinedValues = values.join(",") - - setQueryParams(prev => ({ - ...prev, - filter_table_name: joinedValues - })) - - dispatch( - auditLogTableNameFilterValueUpdated({ - mode, - value: values - }) - ) - }, - [setQueryParams, dispatch, mode] - ) - - // Memoize Paper wrapper props if they're complex (optional here) - const paperProps = useMemo(() => ({p: "xs" as const}), []) - - return ( - - - - ) -}) - -export default TableNameFilter diff --git a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx b/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx deleted file mode 100644 index a49a0a826..000000000 --- a/frontend/apps/ui/src/features/audit/components/TimestampFilter.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import type { - AuditLogQueryParams, - FilterHookReturn, - TimestampFilterType -} from "@/features/audit/types" -import {Button, Group, Paper, Stack} from "@mantine/core" -import {DateTimePicker} from "@mantine/dates" - -import {useAppDispatch, useAppSelector} from "@/app/hooks" -import { - auditLogTimestampFilterValueUpdated, - selectAuditLogTimestampFilterValue -} from "@/features/ui/uiSlice" -import {usePanelMode} from "@/hooks" -import React, {useCallback, useEffect, useMemo} from "react" - -interface TimestampFilterArgs { - setQueryParams: React.Dispatch> -} - -export function useTimestampFilter({ - setQueryParams -}: TimestampFilterArgs): FilterHookReturn { - const mode = usePanelMode() - const range = useAppSelector(s => selectAuditLogTimestampFilterValue(s, mode)) - - const clear = useCallback(() => { - // Single batch update instead of two separate calls - setQueryParams(prev => ({ - ...prev, - filter_timestamp_from: undefined, - filter_timestamp_to: undefined - })) - }, [setQueryParams]) - - useEffect(() => { - // Single batch update instead of separate calls - setQueryParams(prev => ({ - ...prev, - filter_timestamp_from: range?.from || undefined, - filter_timestamp_to: range?.to || undefined - })) - }, [range, setQueryParams]) - - return {clear} -} - -// Memoize the main component -const TimestampFilter = React.memo(({setQueryParams}) => { - const mode = usePanelMode() - const dispatch = useAppDispatch() - const range = useAppSelector(s => selectAuditLogTimestampFilterValue(s, mode)) - - // Memoize handlers to prevent unnecessary re-renders - const onChangeFrom = useCallback( - (value: string | null) => { - const newFrom = value ? new Date(value) : null - - setQueryParams(prev => ({ - ...prev, - filter_timestamp_from: value || undefined - })) - - dispatch( - auditLogTimestampFilterValueUpdated({ - mode, - value: { - from: newFrom?.toISOString() || null, - to: range?.to || null - } - }) - ) - }, - [setQueryParams, dispatch, mode, range?.to] - ) - - const onChangeTo = useCallback( - (value: string | null) => { - const newTo = value ? new Date(value) : null - - // BUG FIX: This was setting filter_timestamp_from instead of filter_timestamp_to - setQueryParams(prev => ({ - ...prev, - filter_timestamp_to: value || undefined // Fixed: was filter_timestamp_from - })) - - dispatch( - auditLogTimestampFilterValueUpdated({ - mode, - value: { - from: range?.from || null, - to: newTo?.toISOString() || null - } - }) - ) - }, - [setQueryParams, dispatch, mode, range?.from] - ) - - // Remove duplicate useEffect - already handled in hook - - // Memoize date calculations to prevent recalculation on every render - const quickSelectRanges = useMemo(() => { - const now = new Date() - - return { - today: () => { - const startOfDay = new Date(now) - startOfDay.setHours(0, 0, 0, 0) - const endOfDay = new Date(now) - endOfDay.setHours(23, 59, 59, 999) - return {from: startOfDay.toISOString(), to: endOfDay.toISOString()} - }, - oneHour: () => { - const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) - return {from: oneHourAgo.toISOString(), to: now.toISOString()} - }, - threeHours: () => { - const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000) - return {from: threeHoursAgo.toISOString(), to: now.toISOString()} - } - } - }, []) // Empty dependency - recalculate only when component mounts - - const handleQuickSelect = useCallback( - (type: "today" | "1hour" | "3hours") => { - let newRange: TimestampFilterType - - switch (type) { - case "today": - newRange = quickSelectRanges.today() - break - case "1hour": - newRange = quickSelectRanges.oneHour() - break - case "3hours": - newRange = quickSelectRanges.threeHours() - break - } - - dispatch( - auditLogTimestampFilterValueUpdated({ - mode, - value: newRange - }) - ) - }, - [dispatch, mode, quickSelectRanges] - ) - - const handleClear = useCallback(() => { - const newRange: TimestampFilterType = {from: null, to: null} - dispatch( - auditLogTimestampFilterValueUpdated({ - mode, - value: newRange - }) - ) - }, [dispatch, mode]) - - // Memoize static props - const paperProps = useMemo(() => ({p: "xs" as const}), []) - const groupProps = useMemo(() => ({justify: "start" as const}), []) - - return ( - - - - - - - - - - - - - - - - ) -}) - -TimestampFilter.displayName = "TimestampFilter" - -export default TimestampFilter