diff --git a/src/govoplan_files/backend/routes/listing.py b/src/govoplan_files/backend/routes/listing.py index c326ea2..8e50e90 100644 --- a/src/govoplan_files/backend/routes/listing.py +++ b/src/govoplan_files/backend/routes/listing.py @@ -11,6 +11,7 @@ from govoplan_files.backend.schemas import ( ) from govoplan_core.db.session import get_session from govoplan_files.backend.storage.files import ( + count_assets_for_user, list_assets_for_user, list_assets_for_user_window, ) @@ -76,6 +77,8 @@ def list_files( owner_id: str | None = None, campaign_id: str | None = None, path_prefix: str | None = None, + campaign_usage: Literal["linked", "unlinked"] | None = None, + audit_relevant: bool | None = None, page_size: int | None = Query(default=None, ge=1, le=1000), cursor: str | None = None, session: Session = Depends(get_session), @@ -84,6 +87,18 @@ def list_files( _ensure_list_owner_access(session, principal, owner_type, owner_id) _ensure_campaign_file_access(session, principal, campaign_id) watermark = _files_delta_watermark(session, principal.tenant_id) + total = count_assets_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + owner_type=owner_type, + owner_id=owner_id, + campaign_id=campaign_id, + path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, + is_admin=_is_admin(principal), + ) effective_page_size = _cursor_page_size(FILES_LIST_CURSOR_SCOPE, cursor, page_size) if effective_page_size is not None: fingerprint = _files_list_fingerprint( @@ -92,6 +107,8 @@ def list_files( owner_id=owner_id, campaign_id=campaign_id, path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, page_size=effective_page_size, ) after_display_path, after_updated_at, after_id = _file_cursor_values( @@ -105,6 +122,8 @@ def list_files( owner_id=owner_id, campaign_id=campaign_id, path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, is_admin=_is_admin(principal), page_size=effective_page_size, after_display_path=after_display_path, @@ -113,6 +132,7 @@ def list_files( ) return FileListResponse( files=_asset_list_response(session, assets, include_shares=True), + total=total, cursor=cursor, next_cursor=_next_file_list_cursor( principal, @@ -121,6 +141,8 @@ def list_files( owner_id=owner_id, campaign_id=campaign_id, path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, page_size=effective_page_size, has_more=has_more, ), @@ -134,9 +156,12 @@ def list_files( owner_id=owner_id, campaign_id=campaign_id, path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, is_admin=_is_admin(principal), ) return FileListResponse( files=_asset_list_response(session, assets, include_shares=True), + total=total, watermark=watermark, ) diff --git a/src/govoplan_files/backend/schemas.py b/src/govoplan_files/backend/schemas.py index 8ecb8c3..aee0aea 100644 --- a/src/govoplan_files/backend/schemas.py +++ b/src/govoplan_files/backend/schemas.py @@ -154,6 +154,7 @@ class FileFolderDeleteResponse(BaseModel): class FileListResponse(BaseModel): files: list[FileAssetResponse] + total: int cursor: str | None = None next_cursor: str | None = None watermark: str | None = None diff --git a/src/govoplan_files/backend/services/list_queries.py b/src/govoplan_files/backend/services/list_queries.py index b8fbfb3..f0e73d7 100644 --- a/src/govoplan_files/backend/services/list_queries.py +++ b/src/govoplan_files/backend/services/list_queries.py @@ -79,6 +79,8 @@ def _files_list_fingerprint( owner_id: str | None, campaign_id: str | None, path_prefix: str | None, + campaign_usage: Literal["linked", "unlinked"] | None, + audit_relevant: bool | None, page_size: int, ) -> str: return keyset_query_fingerprint( @@ -90,6 +92,8 @@ def _files_list_fingerprint( "owner_id": owner_id, "campaign_id": campaign_id, "path_prefix": path_prefix or "", + "campaign_usage": campaign_usage or "", + "audit_relevant": audit_relevant, "sort": "display_path.asc,updated_at.desc,id.asc", "page_size": page_size, }, @@ -175,6 +179,8 @@ def _next_file_list_cursor( owner_id: str | None, campaign_id: str | None, path_prefix: str | None, + campaign_usage: Literal["linked", "unlinked"] | None, + audit_relevant: bool | None, page_size: int, has_more: bool, ) -> str | None: @@ -189,6 +195,8 @@ def _next_file_list_cursor( owner_id=owner_id, campaign_id=campaign_id, path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, page_size=page_size, ), values={ diff --git a/src/govoplan_files/backend/storage/files.py b/src/govoplan_files/backend/storage/files.py index d4ea9ff..e521a6f 100644 --- a/src/govoplan_files/backend/storage/files.py +++ b/src/govoplan_files/backend/storage/files.py @@ -6,7 +6,7 @@ from pathlib import PurePosixPath from typing import Any, Iterable from uuid import uuid4 -from sqlalchemy import and_, or_ +from sqlalchemy import and_, exists, func, or_ from sqlalchemy.orm import Session from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider @@ -352,6 +352,8 @@ def list_assets_for_user( owner_id: str | None = None, campaign_id: str | None = None, path_prefix: str | None = None, + campaign_usage: str | None = None, + audit_relevant: bool | None = None, include_deleted: bool = False, is_admin: bool = False, ) -> list[FileAsset]: @@ -363,6 +365,8 @@ def list_assets_for_user( owner_id=owner_id, campaign_id=campaign_id, path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, include_deleted=include_deleted, is_admin=is_admin, ) @@ -378,6 +382,8 @@ def list_assets_for_user_window( owner_id: str | None = None, campaign_id: str | None = None, path_prefix: str | None = None, + campaign_usage: str | None = None, + audit_relevant: bool | None = None, include_deleted: bool = False, is_admin: bool = False, page_size: int, @@ -393,6 +399,8 @@ def list_assets_for_user_window( owner_id=owner_id, campaign_id=campaign_id, path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, include_deleted=include_deleted, is_admin=is_admin, ) @@ -417,6 +425,8 @@ def _asset_visibility_query_for_user( owner_id: str | None = None, campaign_id: str | None = None, path_prefix: str | None = None, + campaign_usage: str | None = None, + audit_relevant: bool | None = None, include_deleted: bool = False, is_admin: bool = False, ): @@ -451,7 +461,64 @@ def _asset_visibility_query_for_user( prefix = normalize_folder(path_prefix) if prefix: query = query.filter(FileAsset.display_path.like(f"{prefix}/%")) - return query + campaign_share_exists = exists().where( + FileShare.tenant_id == tenant_id, + FileShare.file_asset_id == FileAsset.id, + FileShare.target_type == "campaign", + FileShare.revoked_at.is_(None), + ) + campaign_use_exists = exists().where( + CampaignAttachmentUse.tenant_id == tenant_id, + CampaignAttachmentUse.file_asset_id == FileAsset.id, + ) + if campaign_usage == "linked": + query = query.filter(or_(campaign_share_exists, campaign_use_exists)) + elif campaign_usage == "unlinked": + query = query.filter(~or_(campaign_share_exists, campaign_use_exists)) + + if audit_relevant is not None: + sent_use_exists = exists().where( + CampaignAttachmentUse.tenant_id == tenant_id, + CampaignAttachmentUse.file_asset_id == FileAsset.id, + CampaignAttachmentUse.use_stage == "sent", + ) + query = query.filter(sent_use_exists if audit_relevant else ~sent_use_exists) + return query.distinct() + + +def count_assets_for_user( + session: Session, + *, + tenant_id: str, + user_id: str, + owner_type: str | None = None, + owner_id: str | None = None, + campaign_id: str | None = None, + path_prefix: str | None = None, + campaign_usage: str | None = None, + audit_relevant: bool | None = None, + include_deleted: bool = False, + is_admin: bool = False, +) -> int: + query = _asset_visibility_query_for_user( + session, + tenant_id=tenant_id, + user_id=user_id, + owner_type=owner_type, + owner_id=owner_id, + campaign_id=campaign_id, + path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, + include_deleted=include_deleted, + is_admin=is_admin, + ) + return int( + query.order_by(None) + .with_entities(func.count(func.distinct(FileAsset.id))) + .scalar() + or 0 + ) def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]: diff --git a/tests/test_access_provider.py b/tests/test_access_provider.py index d22e284..bfdf98c 100644 --- a/tests/test_access_provider.py +++ b/tests/test_access_provider.py @@ -2,14 +2,16 @@ from __future__ import annotations import unittest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker from govoplan_access.backend.db.models import Account, Group, User from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.db.base import Base from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare +from govoplan_files.backend.storage.files import count_assets_for_user, list_assets_for_user TENANT_ID = "tenant-1" @@ -75,10 +77,114 @@ class FilesAccessProviderTests(unittest.TestCase): self.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in virtual_items)) self.assertEqual("files.not_found", missing_items[0].source) + def test_file_property_filters_cover_campaign_and_audit_usage(self) -> None: + session = _session() + self.addCleanup(_close_session, session) + _seed_access_subjects(session) + assets = [ + FileAsset( + id=f"file-{index}", + tenant_id=TENANT_ID, + owner_type="user", + owner_user_id=USER_ID, + display_path=f"{index}.pdf", + filename=f"{index}.pdf", + ) + for index in range(1, 4) + ] + session.add_all([ + *assets, + FileShare( + id="share-campaign", + tenant_id=TENANT_ID, + file_asset_id=assets[0].id, + target_type="campaign", + target_id="campaign-1", + permission="read", + ), + ]) + session.commit() + session.execute( + text( + "INSERT INTO campaign_attachment_uses " + "(id, tenant_id, file_asset_id, use_stage) " + "VALUES (:id, :tenant_id, :file_asset_id, :use_stage)" + ), + { + "id": "attachment-use-1", + "tenant_id": TENANT_ID, + "file_asset_id": assets[1].id, + "use_stage": "sent", + }, + ) + session.commit() + + linked = list_assets_for_user( + session, + tenant_id=TENANT_ID, + user_id=USER_ID, + owner_type="user", + owner_id=USER_ID, + campaign_usage="linked", + ) + unlinked = list_assets_for_user( + session, + tenant_id=TENANT_ID, + user_id=USER_ID, + owner_type="user", + owner_id=USER_ID, + campaign_usage="unlinked", + ) + audit_relevant = list_assets_for_user( + session, + tenant_id=TENANT_ID, + user_id=USER_ID, + owner_type="user", + owner_id=USER_ID, + audit_relevant=True, + ) + + self.assertEqual([asset.id for asset in linked], ["file-1", "file-2"]) + self.assertEqual([asset.id for asset in unlinked], ["file-3"]) + self.assertEqual([asset.id for asset in audit_relevant], ["file-2"]) + self.assertEqual( + count_assets_for_user( + session, + tenant_id=TENANT_ID, + user_id=USER_ID, + owner_type="user", + owner_id=USER_ID, + campaign_usage="unlinked", + ), + 1, + ) + def _session(): engine = create_engine("sqlite:///:memory:", future=True) - Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, FileAsset.__table__, FileFolder.__table__, FileShare.__table__]) + Base.metadata.create_all( + bind=engine, + tables=[ + Account.__table__, + User.__table__, + Group.__table__, + ChangeSequenceEntry.__table__, + FileAsset.__table__, + FileFolder.__table__, + FileShare.__table__, + ], + ) + with engine.begin() as connection: + connection.execute( + text( + "CREATE TABLE campaign_attachment_uses (" + "id VARCHAR(36) PRIMARY KEY, " + "tenant_id VARCHAR(36) NOT NULL, " + "file_asset_id VARCHAR(36) NOT NULL, " + "use_stage VARCHAR(20) NOT NULL" + ")" + ) + ) return sessionmaker(bind=engine, future=True)() diff --git a/tests/test_router_contract.py b/tests/test_router_contract.py index 50d941c..7f2c876 100644 --- a/tests/test_router_contract.py +++ b/tests/test_router_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations import unittest from collections import Counter +from inspect import signature from govoplan_files.backend.router import router from govoplan_files.backend.routes.assets import router as assets_router @@ -76,6 +77,18 @@ class FilesRouterContractTests(unittest.TestCase): self.assertIn((("POST",), "/files/bulk-rename"), routes) self.assertIn((("POST",), "/files/transfer"), routes) + def test_file_listing_exposes_structured_property_filters(self) -> None: + route = next( + route + for route in listing_router.routes + if route.path == "/files" and "GET" in (route.methods or ()) + ) + parameters = signature(route.endpoint).parameters + + self.assertIn("campaign_usage", parameters) + self.assertIn("audit_relevant", parameters) + self.assertIn("total", route.response_model.model_fields) + if __name__ == "__main__": unittest.main() diff --git a/webui/package.json b/webui/package.json index 7813095..91e1902 100644 --- a/webui/package.json +++ b/webui/package.json @@ -14,7 +14,8 @@ "./styles/file-manager.css": "./src/styles/file-manager.css" }, "scripts": { - "test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs" + "test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs", + "test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs" }, "peerDependencies": { "@vitejs/plugin-react": "^4.3.4", diff --git a/webui/scripts/test-file-property-filters-structure.mjs b/webui/scripts/test-file-property-filters-structure.mjs new file mode 100644 index 0000000..02dc6d0 --- /dev/null +++ b/webui/scripts/test-file-property-filters-structure.mjs @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; + +const page = readFileSync(new URL("../src/features/files/FilesPage.tsx", import.meta.url), "utf8").replace(/\s+/g, " "); +const api = readFileSync(new URL("../src/api/files.ts", import.meta.url), "utf8").replace(/\s+/g, " "); + +function assertIncludes(source, fragment, message) { + if (!source.includes(fragment.replace(/\s+/g, " "))) throw new Error(message); +} + +assertIncludes( + api, + "campaign_usage?: FileCampaignUsageFilter; audit_relevant?: boolean;", + "the Files API must expose structured campaign and audit filters" +); +assertIncludes( + api, + "do { const response = await listFiles", + "property filtering must traverse every server result page" +); +assertIncludes( + page, + "if (propertyFiltersActive && propertyFilterResults) return propertyFilterResults;", + "server-filtered rows must replace, not narrow, the currently loaded explorer window" +); +assertIncludes( + page, + '', + "the high-priority unlinked campaign-file filter must remain available" +); +assertIncludes( + page, + "setPropertyFilterTotal(response.total);", + "the UI must retain the exact server-side filtered count" +); + +console.log("Files property filters cover the complete server result window."); diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts index ea2b2c4..54ae5fd 100644 --- a/webui/src/api/files.ts +++ b/webui/src/api/files.ts @@ -297,7 +297,8 @@ export type ManagedFile = { shares?: FileShare[]; }; -export type FileListResponse = {files: ManagedFile[];cursor?: string | null;next_cursor?: string | null;watermark?: string | null;}; +export type FileCampaignUsageFilter = "linked" | "unlinked"; +export type FileListResponse = {files: ManagedFile[];total: number;cursor?: string | null;next_cursor?: string | null;watermark?: string | null;}; export type FileDeltaDeletedItem = {id: string;resource_type?: string | null;revision?: string | null;deleted_at?: string | null;}; export type FileDeltaResponse = { files: ManagedFile[]; @@ -383,15 +384,43 @@ payload: {owner_type: "user" | "group";owner_id: string;path: string;recursive?: return apiFetch(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) }); } -export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;page_size?: number;cursor?: string | null;} = {}): Promise { +export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;campaign_usage?: FileCampaignUsageFilter;audit_relevant?: boolean;page_size?: number;cursor?: string | null;} = {}): Promise { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { - if (value) search.set(key, String(value)); + if (value !== undefined && value !== null && value !== "") search.set(key, String(value)); } const suffix = search.toString() ? `?${search.toString()}` : ""; return apiFetch(settings, `/api/v1/files${suffix}`); } +export async function listFilesByProperties( +settings: ApiSettings, +params: { + owner_type: "user" | "group"; + owner_id: string; + path_prefix?: string; + campaign_usage?: FileCampaignUsageFilter; + audit_relevant?: boolean; + page_size?: number; +}) +: Promise<{files: ManagedFile[];total: number;}> { + const pageSize = params.page_size ?? DEFAULT_MANAGED_FILE_WINDOW_SIZE; + let cursor: string | null | undefined = null; + let total = 0; + let files: ManagedFile[] = []; + do { + const response = await listFiles(settings, { + ...params, + page_size: pageSize, + cursor + }); + files = files.concat(response.files); + total = response.total; + cursor = response.next_cursor; + } while (cursor); + return { files, total }; +} + export function listFilesDelta( settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;since?: string;limit?: number;} = {}) diff --git a/webui/src/features/files/FilesPage.tsx b/webui/src/features/files/FilesPage.tsx index d31a965..51ec309 100644 --- a/webui/src/features/files/FilesPage.tsx +++ b/webui/src/features/files/FilesPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; -import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react"; +import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react"; import { Button, ConfirmDialog, @@ -25,6 +25,7 @@ import { downloadFilesAsZip, fetchResourceAccessExplanation, listFilesDelta, + listFilesByProperties, listFileConnectorProfiles, listFileSpaces, listManagedFileSnapshot, @@ -38,6 +39,7 @@ import { type FileConnectorBrowseItem, type FileConnectorProfile, type FileDeltaResponse, + type FileCampaignUsageFilter, type FileFolder, type FileSpace, type ManagedFile, @@ -88,6 +90,7 @@ import { useFileDialogs } from "./hooks/useFileDialogs"; import { useFileDragDropState } from "./hooks/useFileDragDropState"; type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing"; +type AuditRelevantFilter = "" | "true" | "false"; type FileAccessExplanationTarget = { resourceType: "file" | "folder"; resourceId: string; @@ -118,6 +121,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut const [searchCaseSensitive, setSearchCaseSensitive] = useState(false); const [searchActive, setSearchActive] = useState(false); const [searchResults, setSearchResults] = useState(null); + const [campaignUsageFilter, setCampaignUsageFilter] = useState<"" | FileCampaignUsageFilter>(""); + const [auditRelevantFilter, setAuditRelevantFilter] = useState(""); + const [propertyFiltersActive, setPropertyFiltersActive] = useState(false); + const [propertyFilterResults, setPropertyFilterResults] = useState(null); + const [propertyFilterTotal, setPropertyFilterTotal] = useState(null); const [sortColumn, setSortColumn] = useState("name"); const [sortDirection, setSortDirection] = useState("asc"); const [unmatchedCount, setUnmatchedCount] = useState(null); @@ -176,11 +184,19 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut const [renameSuffix, setRenameSuffix] = useState(""); const [renameRecursive, setRenameRecursive] = useState(false); const [renamePreview, setRenamePreview] = useState(null); - const visibleFiles = searchActive && searchResults ? searchResults : files; + const visibleFiles = useMemo(() => { + if (searchActive && searchResults && propertyFiltersActive && propertyFilterResults) { + const ids = new Set(propertyFilterResults.map((file) => file.id)); + return searchResults.filter((file) => ids.has(file.id)); + } + if (propertyFiltersActive && propertyFilterResults) return propertyFilterResults; + if (searchActive && searchResults) return searchResults; + return files; + }, [files, propertyFilterResults, propertyFiltersActive, searchActive, searchResults]); const explorerEntries = useMemo(() => { - const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive); + const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive || propertyFiltersActive); return sortExplorerEntries(entries, sortColumn, sortDirection); - }, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]); + }, [visibleFiles, folders, currentFolder, searchActive, propertyFiltersActive, sortColumn, sortDirection]); const fileListViewportRef = useRef(null); const fileListMeasureRowRef = useRef(null); const managedSpaceLoadsInFlightRef = useRef>(new Set()); @@ -285,7 +301,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut if (!viewport) return; viewport.scrollTop = 0; setFileListScrollTop(0); - }, [activeSpaceId, currentFolder, searchActive, searchResults, sortColumn, sortDirection]); + }, [activeSpaceId, currentFolder, propertyFiltersActive, propertyFilterResults, searchActive, searchResults, sortColumn, sortDirection]); async function loadSpaces() { setBusy(true); @@ -373,6 +389,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut setSearchActive(false); setSearchPattern(""); setSearchResults(null); + setCampaignUsageFilter(""); + setAuditRelevantFilter(""); + setPropertyFiltersActive(false); + setPropertyFilterResults(null); + setPropertyFilterTotal(null); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -410,6 +431,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut setSearchPattern(""); setSearchActive(false); setSearchResults(null); + setCampaignUsageFilter(""); + setAuditRelevantFilter(""); + setPropertyFiltersActive(false); + setPropertyFilterResults(null); + setPropertyFilterTotal(null); setSelectedFileIds(new Set()); setSelectedFolderPaths(new Set()); setConnectorSpaceSelectedItem(null); @@ -431,6 +457,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut setSearchActive(false); setSearchPattern(""); setSearchResults(null); + setCampaignUsageFilter(""); + setAuditRelevantFilter(""); + setPropertyFiltersActive(false); + setPropertyFilterResults(null); + setPropertyFilterTotal(null); } setUnmatchedCount(null); setRenamePreview(null); @@ -1001,6 +1032,50 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut setSelectedFolderPaths(new Set()); } + async function runPropertyFilters( + campaignUsage: "" | FileCampaignUsageFilter, + auditRelevant: AuditRelevantFilter + ) { + if (!activeSpace || activeSpaceIsConnector) return; + if (!campaignUsage && !auditRelevant) { + clearPropertyFilters(); + return; + } + setBusy(true); + setError(""); + setMessage(""); + try { + const response = await listFilesByProperties(settings, { + owner_type: activeSpace.owner_type, + owner_id: activeSpace.owner_id, + path_prefix: currentFolder, + campaign_usage: campaignUsage || undefined, + audit_relevant: auditRelevant ? auditRelevant === "true" : undefined + }); + setCampaignUsageFilter(campaignUsage); + setAuditRelevantFilter(auditRelevant); + setPropertyFilterResults(response.files); + setPropertyFilterTotal(response.total); + setPropertyFiltersActive(true); + setSelectedFileIds(new Set()); + setSelectedFolderPaths(new Set()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + function clearPropertyFilters() { + setCampaignUsageFilter(""); + setAuditRelevantFilter(""); + setPropertyFiltersActive(false); + setPropertyFilterResults(null); + setPropertyFilterTotal(null); + setSelectedFileIds(new Set()); + setSelectedFolderPaths(new Set()); + } + function deleteSelected( fileIds: Set = selectedFileIds, folderPaths: Set = selectedFolderPaths, @@ -2067,6 +2142,15 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut disabled={!activeSpace || activeSpaceIsConnector} onSearch={(patternValue, caseSensitiveValue) => void runPatternSearch(patternValue, caseSensitiveValue)} onClear={() => void clearSearch()} /> + void runPropertyFilters(campaignUsage, auditRelevant)} + onClear={clearPropertyFilters} />
{activeSpaceIsConnector ? connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.remote_connector_space.d8956863" : selectedSummary} @@ -2509,6 +2593,72 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut } +function FilePropertyFilterRow({ + campaignUsage, + auditRelevant, + busy, + active, + disabled, + total, + onApply, + onClear +}: { + campaignUsage: "" | FileCampaignUsageFilter; + auditRelevant: AuditRelevantFilter; + busy: boolean; + active: boolean; + disabled: boolean; + total: number | null; + onApply: (campaignUsage: "" | FileCampaignUsageFilter, auditRelevant: AuditRelevantFilter) => void; + onClear: () => void; +}) { + const [campaignUsageDraft, setCampaignUsageDraft] = useState(campaignUsage); + const [auditRelevantDraft, setAuditRelevantDraft] = useState(auditRelevant); + + useEffect(() => { + setCampaignUsageDraft(campaignUsage); + }, [campaignUsage]); + + useEffect(() => { + setAuditRelevantDraft(auditRelevant); + }, [auditRelevant]); + + return ( +
+
+ ); +} + function FileSearchRow({ value, caseSensitive, diff --git a/webui/src/styles/file-manager.css b/webui/src/styles/file-manager.css index 85c8e3b..bbcd43f 100644 --- a/webui/src/styles/file-manager.css +++ b/webui/src/styles/file-manager.css @@ -11,6 +11,31 @@ padding: 4px 0 10px 14px; } +.file-property-filter-row { + display: flex; + align-items: end; + gap: 12px; + min-width: 0; + padding: 0 0 10px 14px; +} + +.file-property-filter-field { + display: grid; + gap: 4px; + min-width: 180px; +} + +.file-property-filter-field > span, +.file-property-filter-count { + color: var(--muted); + font-size: var(--font-size-sm); +} + +.file-property-filter-count { + align-self: center; + margin-left: auto; +} + .file-list-drop-target.is-active, .file-list-drop-target.is-drop-target { background: var(--line); @@ -565,6 +590,15 @@ .file-search-row { grid-template-columns: 1fr; } + + .file-property-filter-row { + align-items: stretch; + flex-direction: column; + } + + .file-property-filter-count { + margin-left: 0; + } } .file-conflict-summary {