Add server-backed file property filters
This commit is contained in:
@@ -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<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 [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
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 [renameRecursive, setRenameRecursive] = useState(false);
|
||||
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 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<HTMLDivElement | null>(null);
|
||||
const fileListMeasureRowRef = useRef<HTMLDivElement | null>(null);
|
||||
const managedSpaceLoadsInFlightRef = useRef<Set<string>>(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<string> = selectedFileIds,
|
||||
folderPaths: Set<string> = 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()} />
|
||||
<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">
|
||||
<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({
|
||||
value,
|
||||
caseSensitive,
|
||||
|
||||
Reference in New Issue
Block a user