feat: Add filtering to shared links admin table (#8602)

* Add query parameter to shares.list

* Add filter on shared links table

* Additional test
This commit is contained in:
Tom Moor
2025-03-01 17:22:15 -05:00
committed by GitHub
parent 4573b3fea2
commit bed0bf9ec8
6 changed files with 119 additions and 13 deletions

View File

@@ -1,12 +1,13 @@
import { observable } from "mobx";
import { computed, observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import { Searchable } from "./interfaces/Searchable";
class Share extends Model {
class Share extends Model implements Searchable {
static modelName = "Share";
@Field
@@ -65,6 +66,11 @@ class Share extends Model {
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
@computed
get searchContent(): string[] {
return [this.document?.title ?? this.documentTitle];
}
}
export default Share;

View File

@@ -3,10 +3,11 @@ import { observer } from "mobx-react";
import { GlobeIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
@@ -16,17 +17,22 @@ import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { SharesTable } from "./components/SharesTable";
import { StickyFilters } from "./components/StickyFilters";
function Shares() {
const team = useCurrentTeam();
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const { shares, auth } = useStores();
const canShareDocuments = auth.team && auth.team.sharing;
const can = usePolicy(team);
const params = useQuery();
const [query, setQuery] = React.useState("");
const reqParams = React.useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
@@ -44,18 +50,44 @@ function Shares() {
);
const { data, error, loading, next } = useTableRequest({
data: shares.orderedData,
data: shares.findByQuery(reqParams.query ?? ""),
sort,
reqFn: shares.fetchPage,
reqParams,
});
const updateParams = React.useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = React.useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
React.useEffect(() => {
if (error) {
toast.error(t("Could not load shares"));
}
}, [t, error]);
React.useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene title={t("Shared Links")} icon={<GlobeIcon />} wide>
<Heading>{t("Shared Links")}</Heading>
@@ -83,6 +115,14 @@ function Shares() {
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<SharesTable
data={data ?? []}

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
import {
@@ -46,10 +46,10 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
accessor: (share) => share.createdBy,
sortable: false,
component: (share) => (
<Flex align="center" gap={4}>
<Flex align="center" gap={8}>
{share.createdBy && (
<>
<Avatar model={share.createdBy} />
<Avatar model={share.createdBy} size={AvatarSize.Small} />
{share.createdBy.name}
</>
)}

View File

@@ -29,6 +29,7 @@ export type SharesInfoReq = z.infer<typeof SharesInfoSchema>;
export const SharesListSchema = BaseSchema.extend({
body: z.object({
query: z.string().optional(),
sort: z
.string()
.refine((val) => Object.keys(Share.getAttributes()).includes(val), {

View File

@@ -57,6 +57,57 @@ describe("#shares.list", () => {
expect(body.data[0].documentTitle).toBe(document.title);
});
it("should allow filtering by document title", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: {
token: user.getJwtToken(),
query: "test",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should allow filtering by document title and return matching shares", async () => {
const user = await buildUser();
await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
title: "test",
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: {
token: user.getJwtToken(),
query: "test",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(share.id);
expect(body.data[0].documentTitle).toBe("test");
});
it("should not return revoked shares", async () => {
const user = await buildUser();
const document = await buildDocument({

View File

@@ -98,9 +98,10 @@ router.post(
pagination(),
validate(T.SharesListSchema),
async (ctx: APIContext<T.SharesListReq>) => {
const { sort, direction } = ctx.input.body;
const { sort, direction, query } = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "listShares", user.team);
const collectionIds = await user.collectionIds();
const where: WhereOptions<Share> = {
teamId: user.teamId,
@@ -111,12 +112,21 @@ router.post(
},
};
const documentWhere: WhereOptions<Document> = {
teamId: user.teamId,
collectionId: collectionIds,
};
if (query) {
documentWhere.title = {
[Op.iLike]: `%${query}%`,
};
}
if (user.isAdmin) {
delete where.userId;
}
const collectionIds = await user.collectionIds();
const options: FindOptions = {
where,
include: [
@@ -125,9 +135,7 @@ router.post(
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
},
where: documentWhere,
include: [
{
model: Collection.scope({