diff --git a/app/scenes/Document/components/DataLoader.js b/app/scenes/Document/components/DataLoader.js index f8d99c76..03d97ce0 100644 --- a/app/scenes/Document/components/DataLoader.js +++ b/app/scenes/Document/components/DataLoader.js @@ -1,6 +1,7 @@ // @flow import distanceInWordsToNow from "date-fns/distance_in_words_to_now"; import invariant from "invariant"; +import { deburr, sortBy } from "lodash"; import { observable } from "mobx"; import { observer, inject } from "mobx-react"; import * as React from "react"; @@ -97,20 +98,26 @@ class DataLoader extends React.Component { } // default search for anything that doesn't look like a URL - const results = await this.props.documents.search(term); + const results = await this.props.documents.searchTitles(term); - return results - .filter((result) => result.document.title) - .map((result) => { - const time = distanceInWordsToNow(result.document.updatedAt, { + return sortBy( + results.map((document) => { + const time = distanceInWordsToNow(document.updatedAt, { addSuffix: true, }); return { - title: result.document.title, + title: document.title, subtitle: `Updated ${time}`, - url: result.document.url, + url: document.url, }; - }); + }), + (document) => + deburr(document.title) + .toLowerCase() + .startsWith(deburr(term).toLowerCase()) + ? -1 + : 1 + ); }; onCreateLink = async (title: string) => { diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index 61ec9e6c..1ddada7e 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -312,12 +312,25 @@ export default class DocumentsStore extends BaseStore { return this.fetchNamedPage("list", options); }; + @action + searchTitles = async (query: string, options: PaginationParams = {}) => { + const res = await client.get("/documents.search_titles", { + query, + ...options, + }); + invariant(res && res.data, "Search response should be available"); + + // add the documents and associated policies to the store + res.data.forEach(this.add); + this.addPolicies(res.policies); + return res.data; + }; + @action search = async ( query: string, options: PaginationParams = {} ): Promise => { - // $FlowFixMe const compactedOptions = omitBy(options, (o) => !o); const res = await client.get("/documents.search", { ...compactedOptions, @@ -464,7 +477,7 @@ export default class DocumentsStore extends BaseStore { { key: "title", value: title }, { key: "publish", value: options.publish }, { key: "file", value: file }, - ].map((info) => { + ].forEach((info) => { if (typeof info.value === "string" && info.value) { formData.append(info.key, info.value); } diff --git a/package.json b/package.json index 4cdfebe4..3947fb10 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "react-portal": "^4.0.0", "react-router-dom": "^5.1.2", "react-waypoint": "^9.0.2", - "rich-markdown-editor": "^11.0.0-8", + "rich-markdown-editor": "^11.0.0-9", "semver": "^7.3.2", "sequelize": "^6.3.4", "sequelize-cli": "^6.2.0", diff --git a/server/api/documents.js b/server/api/documents.js index 6d13304b..44e54b2a 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -551,6 +551,52 @@ router.post("documents.restore", auth(), async (ctx) => { }; }); +router.post("documents.search_titles", auth(), pagination(), async (ctx) => { + const { query } = ctx.body; + const { offset, limit } = ctx.state.pagination; + const user = ctx.state.user; + ctx.assertPresent(query, "query is required"); + + const collectionIds = await user.collectionIds(); + + const documents = await Document.scope( + { + method: ["withViews", user.id], + }, + { + method: ["withCollection", user.id], + } + ).findAll({ + where: { + title: { + [Op.iLike]: `%${query}%`, + }, + collectionId: collectionIds, + archivedAt: { + [Op.eq]: null, + }, + }, + order: [["updatedAt", "DESC"]], + include: [ + { model: User, as: "createdBy", paranoid: false }, + { model: User, as: "updatedBy", paranoid: false }, + ], + offset, + limit, + }); + + const policies = presentPolicies(user, documents); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + + ctx.body = { + pagination: ctx.state.pagination, + data, + policies, + }; +}); + router.post("documents.search", auth(), pagination(), async (ctx) => { const { query, diff --git a/server/api/documents.test.js b/server/api/documents.test.js index e2494145..176215d8 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -628,6 +628,56 @@ describe("#documents.drafts", () => { }); }); +describe("#documents.search_titles", () => { + it("should return case insensitive results for partial query", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + }); + + const res = await server.post("/api/documents.search_titles", { + body: { token: user.getJwtToken(), query: "SECRET" }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(document.id); + }); + + it("should not include archived or deleted documents", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + archivedAt: new Date(), + }); + + await buildDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + deletedAt: new Date(), + }); + + const res = await server.post("/api/documents.search_titles", { + body: { token: user.getJwtToken(), query: "SECRET" }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + + it("should require authentication", async () => { + const res = await server.post("/api/documents.search_titles"); + expect(res.status).toEqual(401); + }); +}); + describe("#documents.search", () => { it("should return results", async () => { const { user } = await seed(); diff --git a/yarn.lock b/yarn.lock index 434a288e..510194f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9887,10 +9887,10 @@ retry-as-promised@^3.2.0: dependencies: any-promise "^1.3.0" -rich-markdown-editor@^11.0.0-8: - version "11.0.0-8" - resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.0.0-8.tgz#542a33963147077a3c01ba2f689f67714056bbb9" - integrity sha512-TTMVMr33CFlk+AkHKE2/nOZ4VRSwq0aOLodCYotztSwGAQ1a01znZmue1gn/VbIrF7/AtxZ/cnIs3p2hJr82bw== +rich-markdown-editor@^11.0.0-9: + version "11.0.0-9" + resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.0.0-9.tgz#a7a4bfa09fca3cdf3168027e11fd9af46c708680" + integrity sha512-B1q6VbRF/6yjHsYMQEXjgjwPJCSU3mNEmLGsJOF+PZACII5ojg8bV51jGd4W1rTvbIzqnLK4iPWlAbn+hrMtXw== dependencies: copy-to-clipboard "^3.0.8" lodash "^4.17.11"