feat: admin trash filter for user files (#23)

The admin file list silently mixed trashed (parentless) files with live
ones. A new deleted-state filter — All / Live only / Trash only — lets
admins inspect any user's recycle bin, combined with the existing owner
filter.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 6eae386b19
commit f2b32638a1

@ -1496,7 +1496,10 @@
"otherConditions": "Other conditions", "otherConditions": "Other conditions",
"shareLinkExisted": "Has share link", "shareLinkExisted": "Has share link",
"directLinkExisted": "Has direct link", "directLinkExisted": "Has direct link",
"isUploading": "Is uploading" "isUploading": "Is uploading",
"deletedState": "Deleted state",
"liveOnly": "Live files only",
"trashOnly": "Trash only"
}, },
"entity": { "entity": {
"refenenceCount": "Reference count", "refenenceCount": "Reference count",

@ -1496,7 +1496,10 @@
"otherConditions": "其他条件", "otherConditions": "其他条件",
"shareLinkExisted": "存在分享链接", "shareLinkExisted": "存在分享链接",
"directLinkExisted": "存在中转直链", "directLinkExisted": "存在中转直链",
"isUploading": "上传中" "isUploading": "上传中",
"deletedState": "删除状态",
"liveOnly": "仅未删除",
"trashOnly": "仅回收站"
}, },
"entity": { "entity": {
"refenenceCount": "引用次数", "refenenceCount": "引用次数",

@ -1,7 +1,7 @@
import { Box, Button, Checkbox, Popover, PopoverProps, Stack, styled } from "@mui/material"; import { Box, Button, Checkbox, MenuItem, Popover, PopoverProps, Stack, styled } from "@mui/material";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DenseFilledTextField, SmallFormControlLabel } from "../../Common/StyledComponents"; import { DenseFilledTextField, DenseSelect, SmallFormControlLabel } from "../../Common/StyledComponents";
import SettingForm from "../../Pages/Setting/SettingForm"; import SettingForm from "../../Pages/Setting/SettingForm";
import SinglePolicySelectionInput from "../Common/SinglePolicySelectionInput"; import SinglePolicySelectionInput from "../Common/SinglePolicySelectionInput";
@ -18,6 +18,8 @@ export interface FileFilterPopoverProps extends PopoverProps {
setHasDirectLink: (hasDirectLink: boolean) => void; setHasDirectLink: (hasDirectLink: boolean) => void;
isUploading: boolean; isUploading: boolean;
setIsUploading: (isUploading: boolean) => void; setIsUploading: (isUploading: boolean) => void;
deleted: string;
setDeleted: (deleted: string) => void;
clearFilters: () => void; clearFilters: () => void;
} }
@ -40,6 +42,8 @@ const FileFilterPopover = ({
setHasDirectLink, setHasDirectLink,
isUploading, isUploading,
setIsUploading, setIsUploading,
deleted,
setDeleted,
clearFilters, clearFilters,
onClose, onClose,
open, open,
@ -54,6 +58,7 @@ const FileFilterPopover = ({
const [localHasShareLink, setLocalHasShareLink] = useState(hasShareLink); const [localHasShareLink, setLocalHasShareLink] = useState(hasShareLink);
const [localHasDirectLink, setLocalHasDirectLink] = useState(hasDirectLink); const [localHasDirectLink, setLocalHasDirectLink] = useState(hasDirectLink);
const [localIsUploading, setLocalIsUploading] = useState(isUploading); const [localIsUploading, setLocalIsUploading] = useState(isUploading);
const [localDeleted, setLocalDeleted] = useState(deleted);
// Initialize local state when popup opens // Initialize local state when popup opens
useEffect(() => { useEffect(() => {
@ -64,6 +69,7 @@ const FileFilterPopover = ({
setLocalHasShareLink(hasShareLink); setLocalHasShareLink(hasShareLink);
setLocalHasDirectLink(hasDirectLink); setLocalHasDirectLink(hasDirectLink);
setLocalIsUploading(isUploading); setLocalIsUploading(isUploading);
setLocalDeleted(deleted);
} }
}, [open]); }, [open]);
@ -75,6 +81,7 @@ const FileFilterPopover = ({
setHasShareLink(localHasShareLink); setHasShareLink(localHasShareLink);
setHasDirectLink(localHasDirectLink); setHasDirectLink(localHasDirectLink);
setIsUploading(localIsUploading); setIsUploading(localIsUploading);
setDeleted(localDeleted);
onClose?.({}, "backdropClick"); onClose?.({}, "backdropClick");
}; };
@ -86,6 +93,7 @@ const FileFilterPopover = ({
setLocalHasShareLink(false); setLocalHasShareLink(false);
setLocalHasDirectLink(false); setLocalHasDirectLink(false);
setLocalIsUploading(false); setLocalIsUploading(false);
setLocalDeleted("");
clearFilters(); clearFilters();
onClose?.({}, "backdropClick"); onClose?.({}, "backdropClick");
}; };
@ -143,6 +151,19 @@ const FileFilterPopover = ({
/> />
</SettingForm> </SettingForm>
<SettingForm title={t("file.deletedState")} noContainer lgWidth={12}>
<DenseSelect
fullWidth
size="small"
value={localDeleted}
onChange={(e) => setLocalDeleted(e.target.value as string)}
>
<MenuItem value="">{t("user.all")}</MenuItem>
<MenuItem value="false">{t("file.liveOnly")}</MenuItem>
<MenuItem value="true">{t("file.trashOnly")}</MenuItem>
</DenseSelect>
</SettingForm>
<SettingForm title={t("file.otherConditions")} noContainer lgWidth={12}> <SettingForm title={t("file.otherConditions")} noContainer lgWidth={12}>
<Stack spacing={0.5}> <Stack spacing={0.5}>
<SmallFormControlLabel <SmallFormControlLabel

@ -46,6 +46,7 @@ export const NameQuery = "name";
export const HasDirectLinkQuery = "has_direct_link"; export const HasDirectLinkQuery = "has_direct_link";
export const SharedQuery = "shared"; export const SharedQuery = "shared";
export const UploadingQuery = "uploading"; export const UploadingQuery = "uploading";
export const DeletedQuery = "deleted";
const FileSetting = () => { const FileSetting = () => {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
@ -68,6 +69,7 @@ const FileSetting = () => {
const [hasDirectLink, setHasDirectLink] = useQueryState(HasDirectLinkQuery, { defaultValue: "" }); const [hasDirectLink, setHasDirectLink] = useQueryState(HasDirectLinkQuery, { defaultValue: "" });
const [shared, setShared] = useQueryState(SharedQuery, { defaultValue: "" }); const [shared, setShared] = useQueryState(SharedQuery, { defaultValue: "" });
const [uploading, setUploading] = useQueryState(UploadingQuery, { defaultValue: "" }); const [uploading, setUploading] = useQueryState(UploadingQuery, { defaultValue: "" });
const [deleted, setDeleted] = useQueryState(DeletedQuery, { defaultValue: "" });
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const [selected, setSelected] = useState<readonly number[]>([]); const [selected, setSelected] = useState<readonly number[]>([]);
const [createNewOpen, setCreateNewOpen] = useState(false); const [createNewOpen, setCreateNewOpen] = useState(false);
@ -93,11 +95,12 @@ const FileSetting = () => {
setHasDirectLink(""); setHasDirectLink("");
setShared(""); setShared("");
setUploading(""); setUploading("");
}, [setStoragePolicy, setOwner, setName, setHasDirectLink, setShared, setUploading]); setDeleted("");
}, [setStoragePolicy, setOwner, setName, setHasDirectLink, setShared, setUploading, setDeleted]);
useEffect(() => { useEffect(() => {
fetchFiles(); fetchFiles();
}, [page, pageSize, orderBy, orderDirection, storagePolicy, owner, name, hasDirectLink, shared, uploading]); }, [page, pageSize, orderBy, orderDirection, storagePolicy, owner, name, hasDirectLink, shared, uploading, deleted]);
const fetchFiles = () => { const fetchFiles = () => {
setLoading(true); setLoading(true);
@ -115,6 +118,7 @@ const FileSetting = () => {
file_direct_link: hasDirectLink === "true" ? "true" : "", file_direct_link: hasDirectLink === "true" ? "true" : "",
file_shared: shared === "true" ? "true" : "", file_shared: shared === "true" ? "true" : "",
file_metadata: uploading === "true" ? Metadata.upload_session_id : "", file_metadata: uploading === "true" ? Metadata.upload_session_id : "",
file_deleted: deleted,
}, },
}), }),
) )
@ -183,8 +187,8 @@ const FileSetting = () => {
}; };
const hasActiveFilters = useMemo(() => { const hasActiveFilters = useMemo(() => {
return !!(storagePolicy || owner || name || hasDirectLink || shared || uploading); return !!(storagePolicy || owner || name || hasDirectLink || shared || uploading || deleted);
}, [storagePolicy, owner, name, hasDirectLink, shared, uploading]); }, [storagePolicy, owner, name, hasDirectLink, shared, uploading, deleted]);
const handleFileDialogOpen = (id: number) => { const handleFileDialogOpen = (id: number) => {
setFileDialogID(id); setFileDialogID(id);
@ -239,6 +243,8 @@ const FileSetting = () => {
setHasShareLink={(value: boolean) => setShared(value ? "true" : "")} setHasShareLink={(value: boolean) => setShared(value ? "true" : "")}
isUploading={uploading === "true"} isUploading={uploading === "true"}
setIsUploading={(value: boolean) => setUploading(value ? "true" : "")} setIsUploading={(value: boolean) => setUploading(value ? "true" : "")}
deleted={deleted}
setDeleted={setDeleted}
/> />
<SecondaryButton onClick={fetchFiles} disabled={loading} variant={"contained"} startIcon={<ArrowSync />}> <SecondaryButton onClick={fetchFiles} disabled={loading} variant={"contained"} startIcon={<ArrowSync />}>

@ -62,6 +62,9 @@ type (
HasMetadata string HasMetadata string
Shared bool Shared bool
HasDirectLink bool HasDirectLink bool
// Deleted filters by soft-delete state: nil lists everything (current
// behavior), true lists only trash, false only live files.
Deleted *bool
} }
MetadataFilter struct { MetadataFilter struct {
@ -1200,6 +1203,15 @@ func (f *fileClient) FlattenListFiles(ctx context.Context, args *FlattenListFile
query = query.Where(file.HasDirectLinksWith(directlink.DeletedAtIsNil())) query = query.Where(file.HasDirectLinksWith(directlink.DeletedAtIsNil()))
} }
if args.Deleted != nil {
if *args.Deleted {
// Trash = files detached from their parent by soft-delete.
query = query.Where(file.Not(file.HasParent()))
} else {
query = query.Where(file.HasParent())
}
}
query.Order(getFileOrderOption(&ListFileParameters{ query.Order(getFileOrderOption(&ListFileParameters{
PaginationArgs: args.PaginationArgs, PaginationArgs: args.PaginationArgs,
})...) })...)

@ -157,6 +157,7 @@ const (
fileMetadataCondition = "file_metadata" fileMetadataCondition = "file_metadata"
fileSharedCondition = "file_shared" fileSharedCondition = "file_shared"
fileDirectLinkCondition = "file_direct_link" fileDirectLinkCondition = "file_direct_link"
fileDeletedCondition = "file_deleted"
) )
func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error) { func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error) {
@ -177,6 +178,7 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error
metadata string metadata string
shared bool shared bool
directLink bool directLink bool
deleted *bool
) )
if service.Conditions[fileUserCondition] != "" { if service.Conditions[fileUserCondition] != "" {
@ -205,6 +207,13 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error
directLink = true directLink = true
} }
switch service.Conditions[fileDeletedCondition] {
case "true":
deleted = lo.ToPtr(true)
case "false":
deleted = lo.ToPtr(false)
}
res, err := fileClient.FlattenListFiles(ctx, &inventory.FlattenListFileParameters{ res, err := fileClient.FlattenListFiles(ctx, &inventory.FlattenListFileParameters{
PaginationArgs: &inventory.PaginationArgs{ PaginationArgs: &inventory.PaginationArgs{
Page: service.Page - 1, Page: service.Page - 1,
@ -218,6 +227,7 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error
HasMetadata: metadata, HasMetadata: metadata,
Shared: shared, Shared: shared,
HasDirectLink: directLink, HasDirectLink: directLink,
Deleted: deleted,
}) })
if err != nil { if err != nil {

Loading…
Cancel
Save