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",
"shareLinkExisted": "Has share link",
"directLinkExisted": "Has direct link",
"isUploading": "Is uploading"
"isUploading": "Is uploading",
"deletedState": "Deleted state",
"liveOnly": "Live files only",
"trashOnly": "Trash only"
},
"entity": {
"refenenceCount": "Reference count",

@ -1496,7 +1496,10 @@
"otherConditions": "其他条件",
"shareLinkExisted": "存在分享链接",
"directLinkExisted": "存在中转直链",
"isUploading": "上传中"
"isUploading": "上传中",
"deletedState": "删除状态",
"liveOnly": "仅未删除",
"trashOnly": "仅回收站"
},
"entity": {
"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 { useTranslation } from "react-i18next";
import { DenseFilledTextField, SmallFormControlLabel } from "../../Common/StyledComponents";
import { DenseFilledTextField, DenseSelect, SmallFormControlLabel } from "../../Common/StyledComponents";
import SettingForm from "../../Pages/Setting/SettingForm";
import SinglePolicySelectionInput from "../Common/SinglePolicySelectionInput";
@ -18,6 +18,8 @@ export interface FileFilterPopoverProps extends PopoverProps {
setHasDirectLink: (hasDirectLink: boolean) => void;
isUploading: boolean;
setIsUploading: (isUploading: boolean) => void;
deleted: string;
setDeleted: (deleted: string) => void;
clearFilters: () => void;
}
@ -40,6 +42,8 @@ const FileFilterPopover = ({
setHasDirectLink,
isUploading,
setIsUploading,
deleted,
setDeleted,
clearFilters,
onClose,
open,
@ -54,6 +58,7 @@ const FileFilterPopover = ({
const [localHasShareLink, setLocalHasShareLink] = useState(hasShareLink);
const [localHasDirectLink, setLocalHasDirectLink] = useState(hasDirectLink);
const [localIsUploading, setLocalIsUploading] = useState(isUploading);
const [localDeleted, setLocalDeleted] = useState(deleted);
// Initialize local state when popup opens
useEffect(() => {
@ -64,6 +69,7 @@ const FileFilterPopover = ({
setLocalHasShareLink(hasShareLink);
setLocalHasDirectLink(hasDirectLink);
setLocalIsUploading(isUploading);
setLocalDeleted(deleted);
}
}, [open]);
@ -75,6 +81,7 @@ const FileFilterPopover = ({
setHasShareLink(localHasShareLink);
setHasDirectLink(localHasDirectLink);
setIsUploading(localIsUploading);
setDeleted(localDeleted);
onClose?.({}, "backdropClick");
};
@ -86,6 +93,7 @@ const FileFilterPopover = ({
setLocalHasShareLink(false);
setLocalHasDirectLink(false);
setLocalIsUploading(false);
setLocalDeleted("");
clearFilters();
onClose?.({}, "backdropClick");
};
@ -143,6 +151,19 @@ const FileFilterPopover = ({
/>
</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}>
<Stack spacing={0.5}>
<SmallFormControlLabel

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

@ -62,6 +62,9 @@ type (
HasMetadata string
Shared 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 {
@ -1200,6 +1203,15 @@ func (f *fileClient) FlattenListFiles(ctx context.Context, args *FlattenListFile
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{
PaginationArgs: args.PaginationArgs,
})...)

@ -157,6 +157,7 @@ const (
fileMetadataCondition = "file_metadata"
fileSharedCondition = "file_shared"
fileDirectLinkCondition = "file_direct_link"
fileDeletedCondition = "file_deleted"
)
func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error) {
@ -177,6 +178,7 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error
metadata string
shared bool
directLink bool
deleted *bool
)
if service.Conditions[fileUserCondition] != "" {
@ -205,6 +207,13 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error
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{
PaginationArgs: &inventory.PaginationArgs{
Page: service.Page - 1,
@ -218,6 +227,7 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error
HasMetadata: metadata,
Shared: shared,
HasDirectLink: directLink,
Deleted: deleted,
})
if err != nil {

Loading…
Cancel
Save