Add server-backed file property filters
This commit is contained in:
@@ -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",
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
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<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();
|
||||
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<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(
|
||||
settings: ApiSettings,
|
||||
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 { 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user