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({