diff --git a/app/models/Share.ts b/app/models/Share.ts index f6fee1ffc3..fd066a98a3 100644 --- a/app/models/Share.ts +++ b/app/models/Share.ts @@ -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; diff --git a/app/scenes/Settings/Shares.tsx b/app/scenes/Settings/Shares.tsx index e10e55f9c5..1131c82633 100644 --- a/app/scenes/Settings/Shares.tsx +++ b/app/scenes/Settings/Shares.tsx @@ -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 ( } wide> {t("Shared Links")} @@ -83,6 +115,14 @@ function Shares() { + + + share.createdBy, sortable: false, component: (share) => ( - + {share.createdBy && ( <> - + {share.createdBy.name} )} diff --git a/server/routes/api/shares/schema.ts b/server/routes/api/shares/schema.ts index 4c5f469488..1b6b782e0c 100644 --- a/server/routes/api/shares/schema.ts +++ b/server/routes/api/shares/schema.ts @@ -29,6 +29,7 @@ export type SharesInfoReq = z.infer; export const SharesListSchema = BaseSchema.extend({ body: z.object({ + query: z.string().optional(), sort: z .string() .refine((val) => Object.keys(Share.getAttributes()).includes(val), { diff --git a/server/routes/api/shares/shares.test.ts b/server/routes/api/shares/shares.test.ts index 58f072718a..993ce345c1 100644 --- a/server/routes/api/shares/shares.test.ts +++ b/server/routes/api/shares/shares.test.ts @@ -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({ diff --git a/server/routes/api/shares/shares.ts b/server/routes/api/shares/shares.ts index 8dcb32e74b..289552a06b 100644 --- a/server/routes/api/shares/shares.ts +++ b/server/routes/api/shares/shares.ts @@ -98,9 +98,10 @@ router.post( pagination(), validate(T.SharesListSchema), async (ctx: APIContext) => { - 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 = { teamId: user.teamId, @@ -111,12 +112,21 @@ router.post( }, }; + const documentWhere: WhereOptions = { + 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({