Add server-backed file property filters
This commit is contained in:
@@ -11,6 +11,7 @@ from govoplan_files.backend.schemas import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_files.backend.storage.files import (
|
from govoplan_files.backend.storage.files import (
|
||||||
|
count_assets_for_user,
|
||||||
list_assets_for_user,
|
list_assets_for_user,
|
||||||
list_assets_for_user_window,
|
list_assets_for_user_window,
|
||||||
)
|
)
|
||||||
@@ -76,6 +77,8 @@ def list_files(
|
|||||||
owner_id: str | None = None,
|
owner_id: str | None = None,
|
||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
path_prefix: 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),
|
page_size: int | None = Query(default=None, ge=1, le=1000),
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -84,6 +87,18 @@ def list_files(
|
|||||||
_ensure_list_owner_access(session, principal, owner_type, owner_id)
|
_ensure_list_owner_access(session, principal, owner_type, owner_id)
|
||||||
_ensure_campaign_file_access(session, principal, campaign_id)
|
_ensure_campaign_file_access(session, principal, campaign_id)
|
||||||
watermark = _files_delta_watermark(session, principal.tenant_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)
|
effective_page_size = _cursor_page_size(FILES_LIST_CURSOR_SCOPE, cursor, page_size)
|
||||||
if effective_page_size is not None:
|
if effective_page_size is not None:
|
||||||
fingerprint = _files_list_fingerprint(
|
fingerprint = _files_list_fingerprint(
|
||||||
@@ -92,6 +107,8 @@ def list_files(
|
|||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
path_prefix=path_prefix,
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
page_size=effective_page_size,
|
page_size=effective_page_size,
|
||||||
)
|
)
|
||||||
after_display_path, after_updated_at, after_id = _file_cursor_values(
|
after_display_path, after_updated_at, after_id = _file_cursor_values(
|
||||||
@@ -105,6 +122,8 @@ def list_files(
|
|||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
path_prefix=path_prefix,
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
is_admin=_is_admin(principal),
|
is_admin=_is_admin(principal),
|
||||||
page_size=effective_page_size,
|
page_size=effective_page_size,
|
||||||
after_display_path=after_display_path,
|
after_display_path=after_display_path,
|
||||||
@@ -113,6 +132,7 @@ def list_files(
|
|||||||
)
|
)
|
||||||
return FileListResponse(
|
return FileListResponse(
|
||||||
files=_asset_list_response(session, assets, include_shares=True),
|
files=_asset_list_response(session, assets, include_shares=True),
|
||||||
|
total=total,
|
||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
next_cursor=_next_file_list_cursor(
|
next_cursor=_next_file_list_cursor(
|
||||||
principal,
|
principal,
|
||||||
@@ -121,6 +141,8 @@ def list_files(
|
|||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
path_prefix=path_prefix,
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
page_size=effective_page_size,
|
page_size=effective_page_size,
|
||||||
has_more=has_more,
|
has_more=has_more,
|
||||||
),
|
),
|
||||||
@@ -134,9 +156,12 @@ def list_files(
|
|||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
path_prefix=path_prefix,
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
is_admin=_is_admin(principal),
|
is_admin=_is_admin(principal),
|
||||||
)
|
)
|
||||||
return FileListResponse(
|
return FileListResponse(
|
||||||
files=_asset_list_response(session, assets, include_shares=True),
|
files=_asset_list_response(session, assets, include_shares=True),
|
||||||
|
total=total,
|
||||||
watermark=watermark,
|
watermark=watermark,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class FileFolderDeleteResponse(BaseModel):
|
|||||||
|
|
||||||
class FileListResponse(BaseModel):
|
class FileListResponse(BaseModel):
|
||||||
files: list[FileAssetResponse]
|
files: list[FileAssetResponse]
|
||||||
|
total: int
|
||||||
cursor: str | None = None
|
cursor: str | None = None
|
||||||
next_cursor: str | None = None
|
next_cursor: str | None = None
|
||||||
watermark: str | None = None
|
watermark: str | None = None
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ def _files_list_fingerprint(
|
|||||||
owner_id: str | None,
|
owner_id: str | None,
|
||||||
campaign_id: str | None,
|
campaign_id: str | None,
|
||||||
path_prefix: str | None,
|
path_prefix: str | None,
|
||||||
|
campaign_usage: Literal["linked", "unlinked"] | None,
|
||||||
|
audit_relevant: bool | None,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
return keyset_query_fingerprint(
|
return keyset_query_fingerprint(
|
||||||
@@ -90,6 +92,8 @@ def _files_list_fingerprint(
|
|||||||
"owner_id": owner_id,
|
"owner_id": owner_id,
|
||||||
"campaign_id": campaign_id,
|
"campaign_id": campaign_id,
|
||||||
"path_prefix": path_prefix or "",
|
"path_prefix": path_prefix or "",
|
||||||
|
"campaign_usage": campaign_usage or "",
|
||||||
|
"audit_relevant": audit_relevant,
|
||||||
"sort": "display_path.asc,updated_at.desc,id.asc",
|
"sort": "display_path.asc,updated_at.desc,id.asc",
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
},
|
},
|
||||||
@@ -175,6 +179,8 @@ def _next_file_list_cursor(
|
|||||||
owner_id: str | None,
|
owner_id: str | None,
|
||||||
campaign_id: str | None,
|
campaign_id: str | None,
|
||||||
path_prefix: str | None,
|
path_prefix: str | None,
|
||||||
|
campaign_usage: Literal["linked", "unlinked"] | None,
|
||||||
|
audit_relevant: bool | None,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
has_more: bool,
|
has_more: bool,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@@ -189,6 +195,8 @@ def _next_file_list_cursor(
|
|||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
path_prefix=path_prefix,
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
),
|
),
|
||||||
values={
|
values={
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import PurePosixPath
|
|||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, exists, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
|
||||||
@@ -352,6 +352,8 @@ def list_assets_for_user(
|
|||||||
owner_id: str | None = None,
|
owner_id: str | None = None,
|
||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
path_prefix: str | None = None,
|
path_prefix: str | None = None,
|
||||||
|
campaign_usage: str | None = None,
|
||||||
|
audit_relevant: bool | None = None,
|
||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
) -> list[FileAsset]:
|
) -> list[FileAsset]:
|
||||||
@@ -363,6 +365,8 @@ def list_assets_for_user(
|
|||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
path_prefix=path_prefix,
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
include_deleted=include_deleted,
|
include_deleted=include_deleted,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
)
|
)
|
||||||
@@ -378,6 +382,8 @@ def list_assets_for_user_window(
|
|||||||
owner_id: str | None = None,
|
owner_id: str | None = None,
|
||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
path_prefix: str | None = None,
|
path_prefix: str | None = None,
|
||||||
|
campaign_usage: str | None = None,
|
||||||
|
audit_relevant: bool | None = None,
|
||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
@@ -393,6 +399,8 @@ def list_assets_for_user_window(
|
|||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
path_prefix=path_prefix,
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
include_deleted=include_deleted,
|
include_deleted=include_deleted,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
)
|
)
|
||||||
@@ -417,6 +425,8 @@ def _asset_visibility_query_for_user(
|
|||||||
owner_id: str | None = None,
|
owner_id: str | None = None,
|
||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
path_prefix: str | None = None,
|
path_prefix: str | None = None,
|
||||||
|
campaign_usage: str | None = None,
|
||||||
|
audit_relevant: bool | None = None,
|
||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
):
|
):
|
||||||
@@ -451,7 +461,64 @@ def _asset_visibility_query_for_user(
|
|||||||
prefix = normalize_folder(path_prefix)
|
prefix = normalize_folder(path_prefix)
|
||||||
if prefix:
|
if prefix:
|
||||||
query = query.filter(FileAsset.display_path.like(f"{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]:
|
def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]:
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from govoplan_access.backend.db.models import Account, Group, User
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
from govoplan_core.core.access import PrincipalRef
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id
|
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.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"
|
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.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in virtual_items))
|
||||||
self.assertEqual("files.not_found", missing_items[0].source)
|
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():
|
def _session():
|
||||||
engine = create_engine("sqlite:///:memory:", future=True)
|
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)()
|
return sessionmaker(bind=engine, future=True)()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from inspect import signature
|
||||||
|
|
||||||
from govoplan_files.backend.router import router
|
from govoplan_files.backend.router import router
|
||||||
from govoplan_files.backend.routes.assets import router as assets_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/bulk-rename"), routes)
|
||||||
self.assertIn((("POST",), "/files/transfer"), 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"./styles/file-manager.css": "./src/styles/file-manager.css"
|
"./styles/file-manager.css": "./src/styles/file-manager.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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": {
|
"peerDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
36
webui/scripts/test-file-property-filters-structure.mjs
Normal file
36
webui/scripts/test-file-property-filters-structure.mjs
Normal file
@@ -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,
|
||||||
|
'<option value="unlinked">Not linked or used</option>',
|
||||||
|
"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.");
|
||||||
@@ -297,7 +297,8 @@ export type ManagedFile = {
|
|||||||
shares?: FileShare[];
|
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 FileDeltaDeletedItem = {id: string;resource_type?: string | null;revision?: string | null;deleted_at?: string | null;};
|
||||||
export type FileDeltaResponse = {
|
export type FileDeltaResponse = {
|
||||||
files: ManagedFile[];
|
files: ManagedFile[];
|
||||||
@@ -383,15 +384,43 @@ payload: {owner_type: "user" | "group";owner_id: string;path: string;recursive?:
|
|||||||
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
|
return apiFetch<FolderDeleteResponse>(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<FileListResponse> {
|
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<FileListResponse> {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
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()}` : "";
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
|
return apiFetch<FileListResponse>(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(
|
export function listFilesDelta(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;since?: string;limit?: number;} = {})
|
params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;since?: string;limit?: number;} = {})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
|
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 {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
downloadFilesAsZip,
|
downloadFilesAsZip,
|
||||||
fetchResourceAccessExplanation,
|
fetchResourceAccessExplanation,
|
||||||
listFilesDelta,
|
listFilesDelta,
|
||||||
|
listFilesByProperties,
|
||||||
listFileConnectorProfiles,
|
listFileConnectorProfiles,
|
||||||
listFileSpaces,
|
listFileSpaces,
|
||||||
listManagedFileSnapshot,
|
listManagedFileSnapshot,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
type FileConnectorBrowseItem,
|
type FileConnectorBrowseItem,
|
||||||
type FileConnectorProfile,
|
type FileConnectorProfile,
|
||||||
type FileDeltaResponse,
|
type FileDeltaResponse,
|
||||||
|
type FileCampaignUsageFilter,
|
||||||
type FileFolder,
|
type FileFolder,
|
||||||
type FileSpace,
|
type FileSpace,
|
||||||
type ManagedFile,
|
type ManagedFile,
|
||||||
@@ -88,6 +90,7 @@ import { useFileDialogs } from "./hooks/useFileDialogs";
|
|||||||
import { useFileDragDropState } from "./hooks/useFileDragDropState";
|
import { useFileDragDropState } from "./hooks/useFileDragDropState";
|
||||||
|
|
||||||
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
|
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
|
||||||
|
type AuditRelevantFilter = "" | "true" | "false";
|
||||||
type FileAccessExplanationTarget = {
|
type FileAccessExplanationTarget = {
|
||||||
resourceType: "file" | "folder";
|
resourceType: "file" | "folder";
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
@@ -118,6 +121,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
|
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
|
||||||
const [searchActive, setSearchActive] = useState(false);
|
const [searchActive, setSearchActive] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<ManagedFile[] | null>(null);
|
const [searchResults, setSearchResults] = useState<ManagedFile[] | null>(null);
|
||||||
|
const [campaignUsageFilter, setCampaignUsageFilter] = useState<"" | FileCampaignUsageFilter>("");
|
||||||
|
const [auditRelevantFilter, setAuditRelevantFilter] = useState<AuditRelevantFilter>("");
|
||||||
|
const [propertyFiltersActive, setPropertyFiltersActive] = useState(false);
|
||||||
|
const [propertyFilterResults, setPropertyFilterResults] = useState<ManagedFile[] | null>(null);
|
||||||
|
const [propertyFilterTotal, setPropertyFilterTotal] = useState<number | null>(null);
|
||||||
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||||
const [unmatchedCount, setUnmatchedCount] = useState<number | null>(null);
|
const [unmatchedCount, setUnmatchedCount] = useState<number | null>(null);
|
||||||
@@ -176,11 +184,19 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
const [renameSuffix, setRenameSuffix] = useState("");
|
const [renameSuffix, setRenameSuffix] = useState("");
|
||||||
const [renameRecursive, setRenameRecursive] = useState(false);
|
const [renameRecursive, setRenameRecursive] = useState(false);
|
||||||
const [renamePreview, setRenamePreview] = useState<RenameResponse | null>(null);
|
const [renamePreview, setRenamePreview] = useState<RenameResponse | null>(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 explorerEntries = useMemo(() => {
|
||||||
const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive);
|
const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive || propertyFiltersActive);
|
||||||
return sortExplorerEntries(entries, sortColumn, sortDirection);
|
return sortExplorerEntries(entries, sortColumn, sortDirection);
|
||||||
}, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]);
|
}, [visibleFiles, folders, currentFolder, searchActive, propertyFiltersActive, sortColumn, sortDirection]);
|
||||||
const fileListViewportRef = useRef<HTMLDivElement | null>(null);
|
const fileListViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const fileListMeasureRowRef = useRef<HTMLDivElement | null>(null);
|
const fileListMeasureRowRef = useRef<HTMLDivElement | null>(null);
|
||||||
const managedSpaceLoadsInFlightRef = useRef<Set<string>>(new Set());
|
const managedSpaceLoadsInFlightRef = useRef<Set<string>>(new Set());
|
||||||
@@ -285,7 +301,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
if (!viewport) return;
|
if (!viewport) return;
|
||||||
viewport.scrollTop = 0;
|
viewport.scrollTop = 0;
|
||||||
setFileListScrollTop(0);
|
setFileListScrollTop(0);
|
||||||
}, [activeSpaceId, currentFolder, searchActive, searchResults, sortColumn, sortDirection]);
|
}, [activeSpaceId, currentFolder, propertyFiltersActive, propertyFilterResults, searchActive, searchResults, sortColumn, sortDirection]);
|
||||||
|
|
||||||
async function loadSpaces() {
|
async function loadSpaces() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -373,6 +389,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
setSearchActive(false);
|
setSearchActive(false);
|
||||||
setSearchPattern("");
|
setSearchPattern("");
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
|
setCampaignUsageFilter("");
|
||||||
|
setAuditRelevantFilter("");
|
||||||
|
setPropertyFiltersActive(false);
|
||||||
|
setPropertyFilterResults(null);
|
||||||
|
setPropertyFilterTotal(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -410,6 +431,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
setSearchPattern("");
|
setSearchPattern("");
|
||||||
setSearchActive(false);
|
setSearchActive(false);
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
|
setCampaignUsageFilter("");
|
||||||
|
setAuditRelevantFilter("");
|
||||||
|
setPropertyFiltersActive(false);
|
||||||
|
setPropertyFilterResults(null);
|
||||||
|
setPropertyFilterTotal(null);
|
||||||
setSelectedFileIds(new Set());
|
setSelectedFileIds(new Set());
|
||||||
setSelectedFolderPaths(new Set());
|
setSelectedFolderPaths(new Set());
|
||||||
setConnectorSpaceSelectedItem(null);
|
setConnectorSpaceSelectedItem(null);
|
||||||
@@ -431,6 +457,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
setSearchActive(false);
|
setSearchActive(false);
|
||||||
setSearchPattern("");
|
setSearchPattern("");
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
|
setCampaignUsageFilter("");
|
||||||
|
setAuditRelevantFilter("");
|
||||||
|
setPropertyFiltersActive(false);
|
||||||
|
setPropertyFilterResults(null);
|
||||||
|
setPropertyFilterTotal(null);
|
||||||
}
|
}
|
||||||
setUnmatchedCount(null);
|
setUnmatchedCount(null);
|
||||||
setRenamePreview(null);
|
setRenamePreview(null);
|
||||||
@@ -1001,6 +1032,50 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
setSelectedFolderPaths(new Set());
|
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(
|
function deleteSelected(
|
||||||
fileIds: Set<string> = selectedFileIds,
|
fileIds: Set<string> = selectedFileIds,
|
||||||
folderPaths: Set<string> = selectedFolderPaths,
|
folderPaths: Set<string> = selectedFolderPaths,
|
||||||
@@ -2067,6 +2142,15 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
disabled={!activeSpace || activeSpaceIsConnector}
|
disabled={!activeSpace || activeSpaceIsConnector}
|
||||||
onSearch={(patternValue, caseSensitiveValue) => void runPatternSearch(patternValue, caseSensitiveValue)}
|
onSearch={(patternValue, caseSensitiveValue) => void runPatternSearch(patternValue, caseSensitiveValue)}
|
||||||
onClear={() => void clearSearch()} />
|
onClear={() => void clearSearch()} />
|
||||||
|
<FilePropertyFilterRow
|
||||||
|
campaignUsage={campaignUsageFilter}
|
||||||
|
auditRelevant={auditRelevantFilter}
|
||||||
|
busy={busy}
|
||||||
|
active={propertyFiltersActive}
|
||||||
|
disabled={!activeSpace || activeSpaceIsConnector}
|
||||||
|
total={propertyFilterTotal}
|
||||||
|
onApply={(campaignUsage, auditRelevant) => void runPropertyFilters(campaignUsage, auditRelevant)}
|
||||||
|
onClear={clearPropertyFilters} />
|
||||||
|
|
||||||
<div className="file-list-meta">
|
<div className="file-list-meta">
|
||||||
<span>{activeSpaceIsConnector ? connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.remote_connector_space.d8956863" : selectedSummary}</span>
|
<span>{activeSpaceIsConnector ? connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.remote_connector_space.d8956863" : selectedSummary}</span>
|
||||||
@@ -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 (
|
||||||
|
<div className="file-property-filter-row">
|
||||||
|
<ListFilter size={17} aria-hidden="true" />
|
||||||
|
<label className="file-property-filter-field">
|
||||||
|
<span>Campaign use</span>
|
||||||
|
<select
|
||||||
|
value={campaignUsageDraft}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onChange={(event) => setCampaignUsageDraft(event.target.value as "" | FileCampaignUsageFilter)}>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="linked">Linked or used</option>
|
||||||
|
<option value="unlinked">Not linked or used</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="file-property-filter-field">
|
||||||
|
<span>Audit evidence</span>
|
||||||
|
<select
|
||||||
|
value={auditRelevantDraft}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onChange={(event) => setAuditRelevantDraft(event.target.value as AuditRelevantFilter)}>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="true">Audit relevant</option>
|
||||||
|
<option value="false">Not audit relevant</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
onClick={() => onApply(campaignUsageDraft, auditRelevantDraft)}
|
||||||
|
disabled={busy || disabled || !campaignUsageDraft && !auditRelevantDraft}>
|
||||||
|
Apply filters
|
||||||
|
</Button>
|
||||||
|
{active && <Button onClick={onClear} disabled={busy}>Clear filters</Button>}
|
||||||
|
{active && total !== null && <span className="file-property-filter-count">{total} matching file{total === 1 ? "" : "s"}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FileSearchRow({
|
function FileSearchRow({
|
||||||
value,
|
value,
|
||||||
caseSensitive,
|
caseSensitive,
|
||||||
|
|||||||
@@ -11,6 +11,31 @@
|
|||||||
padding: 4px 0 10px 14px;
|
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-active,
|
||||||
.file-list-drop-target.is-drop-target {
|
.file-list-drop-target.is-drop-target {
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
@@ -565,6 +590,15 @@
|
|||||||
.file-search-row {
|
.file-search-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-property-filter-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-property-filter-count {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-conflict-summary {
|
.file-conflict-summary {
|
||||||
|
|||||||
Reference in New Issue
Block a user