From aa3ebfbcea3bae7f7c35e5e605916a3ed5840f8c Mon Sep 17 00:00:00 2001 From: James Fenn Date: Thu, 19 Oct 2023 01:59:12 -0400 Subject: [PATCH] add unicorn data to search API response, fix type errors --- __mocks__/data/mock-collection.ts | 1 - __mocks__/data/mock-post.ts | 45 ++++--------------- __mocks__/data/mock-role.ts | 4 +- __mocks__/data/mock-unicorn.ts | 6 ++- api/search.ts | 43 +++++++++++++----- build-scripts/search-index.ts | 17 ++++++- .../framework-field-guide/index.astro | 4 +- src/views/explore/page.astro | 14 +++++- .../search/components/filter-display.tsx | 27 ++++------- src/views/search/search-page.spec.tsx | 20 ++++++++- src/views/search/search-page.tsx | 20 ++++----- src/views/search/types.ts | 11 +++++ 12 files changed, 123 insertions(+), 89 deletions(-) create mode 100644 src/views/search/types.ts diff --git a/__mocks__/data/mock-collection.ts b/__mocks__/data/mock-collection.ts index 76336f3b..74e54965 100644 --- a/__mocks__/data/mock-collection.ts +++ b/__mocks__/data/mock-collection.ts @@ -23,7 +23,6 @@ export const MockCollection: ExtendedCollectionInfo = { }, locales: ["en"], locale: "en", - posts: [MockPost], slug: "this-collection-name-here", title: "Collection title", description: "This is a short description dunno why this would be this short", diff --git a/__mocks__/data/mock-post.ts b/__mocks__/data/mock-post.ts index bdfbcc25..4f6bb292 100644 --- a/__mocks__/data/mock-post.ts +++ b/__mocks__/data/mock-post.ts @@ -1,33 +1,25 @@ import { MockUnicorn, MockUnicornTwo } from "./mock-unicorn"; import { MockLicense } from "./mock-license"; -import { ExtendedPostInfo } from "types/index"; +import { PostInfo } from "types/index"; -export const MockPost: ExtendedPostInfo = { - excerpt: "This would be an auto generated excerpt of the post in particular", +export const MockPost: PostInfo = { title: "Post title", published: "10-10-2010T00:00:00.000Z", publishedMeta: "October 10, 2010", tags: ["item1"], description: "This is a short description dunno why this would be this short", authors: [MockUnicorn.id], - authorsMeta: [MockUnicorn], license: MockLicense.id, - licenseMeta: MockLicense, locale: "en", locales: ["en", "es"], slug: "this-post-name-here", - headingsWithId: [], + path: "path", wordCount: 10000, - contentMeta: "", - Content: {} as never, - suggestedArticles: [] as never, attached: [], socialImg: "img.png", }; -export const MockMultiAuthorPost: ExtendedPostInfo = { - excerpt: - "This would be a second auto generated excerpt of the post in particular", +export const MockMultiAuthorPost: PostInfo = { title: "Another post title", published: "10-20-2010T00:00:00.000Z", publishedMeta: "October 20, 2010", @@ -35,24 +27,17 @@ export const MockMultiAuthorPost: ExtendedPostInfo = { description: "This is another short description dunno why this would be this short", authors: [MockUnicornTwo.id, MockUnicorn.id], - authorsMeta: [MockUnicornTwo, MockUnicorn], license: MockLicense.id, - licenseMeta: MockLicense, locale: "en", locales: ["en", "es"], slug: "this-other-post-name-here", - headingsWithId: [], + path: "path", wordCount: 100000, - contentMeta: "", - Content: {} as never, - suggestedArticles: [] as never, attached: [], socialImg: "img.png", }; -export const MockMuliLanguagePost: ExtendedPostInfo = { - excerpt: - "This would be a second auto generated excerpt of the post in particular", +export const MockMuliLanguagePost: PostInfo = { title: "Another post title", published: "10-20-2010T00:00:00.000Z", publishedMeta: "October 20, 2010", @@ -60,24 +45,17 @@ export const MockMuliLanguagePost: ExtendedPostInfo = { description: "This is another short description dunno why this would be this short", authors: [MockUnicornTwo.id, MockUnicorn.id], - authorsMeta: [MockUnicornTwo, MockUnicorn], license: MockLicense.id, - licenseMeta: MockLicense, locale: "en", locales: ["en", "es"], slug: "this-other-post-name-here", - headingsWithId: [], + path: "path", wordCount: 100000, - contentMeta: "", - Content: {} as never, - suggestedArticles: [] as never, attached: [], socialImg: "img.png", }; -export const MockCanonicalPost: ExtendedPostInfo = { - excerpt: - "This would be a second auto generated excerpt of the post in particular", +export const MockCanonicalPost: PostInfo = { title: "Another post title", published: "10-20-2010T00:00:00.000Z", publishedMeta: "October 20, 2010", @@ -86,17 +64,12 @@ export const MockCanonicalPost: ExtendedPostInfo = { description: "This is another short description dunno why this would be this short", authors: [MockUnicornTwo.id, MockUnicorn.id], - authorsMeta: [MockUnicornTwo, MockUnicorn], license: MockLicense.id, - licenseMeta: MockLicense, locale: "en", locales: ["en", "es"], slug: "this-other-post-name-here", - headingsWithId: [], + path: "path", wordCount: 100000, - contentMeta: "", - Content: {} as never, - suggestedArticles: [] as never, attached: [], socialImg: "img.png", }; diff --git a/__mocks__/data/mock-role.ts b/__mocks__/data/mock-role.ts index d96a0501..3e5eec41 100644 --- a/__mocks__/data/mock-role.ts +++ b/__mocks__/data/mock-role.ts @@ -1,6 +1,6 @@ -import { RolesEnum } from "types/RolesInfo"; +import { RolesInfo } from "types/RolesInfo"; -export const MockRole: RolesEnum = { +export const MockRole: RolesInfo = { id: "developer", prettyname: "Developer", }; diff --git a/__mocks__/data/mock-unicorn.ts b/__mocks__/data/mock-unicorn.ts index 0be22f28..e5b6ee42 100644 --- a/__mocks__/data/mock-unicorn.ts +++ b/__mocks__/data/mock-unicorn.ts @@ -6,10 +6,11 @@ export const MockUnicorn: UnicornInfo = { firstName: "Joe", lastName: "Other", id: "joe", + locale: "en", + locales: ["en"], description: "Exists", color: "red", roles: [MockRole.id], - rolesMeta: [MockRole], socials: { twitter: "twtrusrname", github: "ghusrname", @@ -36,10 +37,11 @@ export const MockUnicornTwo: UnicornInfo = { firstName: "Diane", lastName: "", id: "diane", + locale: "en", + locales: ["en"], description: "Is a human", color: "blue", roles: [MockRole.id], - rolesMeta: [MockRole], socials: { twitter: "twtrusrname2", github: "ghusrname2", diff --git a/api/search.ts b/api/search.ts index 1e048f1d..2bbb56bf 100644 --- a/api/search.ts +++ b/api/search.ts @@ -2,7 +2,8 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import Fuse from "fuse.js"; import { createRequire } from "node:module"; -import type { ExtendedPostInfo } from "types/index"; +import { CollectionInfo, UnicornInfo, type PostInfo } from "types/index"; +import type { ServerReturnType } from "../src/views/search/types"; const require = createRequire(import.meta.url); const searchIndex = require("./searchIndex.json"); @@ -12,7 +13,7 @@ const collectionIndex = Fuse.parseIndex(searchIndex.collectionIndex); const posts = searchIndex.posts; const collections = searchIndex.collections; -const postFuse = new Fuse( +const postFuse = new Fuse( posts, { threshold: 0.3, @@ -23,7 +24,7 @@ const postFuse = new Fuse( postIndex, ); -const collectionFuse = new Fuse( +const collectionFuse = new Fuse( collections, { threshold: 0.3, @@ -34,35 +35,53 @@ const collectionFuse = new Fuse( collectionIndex, ); -export default async (req: VercelRequest, res: VercelResponse) => { +const unicorns: Record = searchIndex.unicorns; + +function runQuery(req: VercelRequest): ServerReturnType { // TODO: `pickdeep` only required fields const searchStr = req?.query?.query as string; if (!searchStr) { - res.send({ + return { + unicorns: {}, posts: [], totalPosts: 0, collections: [], totalCollections: 0, - }); - return; + }; } if (searchStr === "*") { - res.send({ + return { + unicorns, posts, totalPosts: posts.length, collections, totalCollections: collections.length, - }); - return; + }; } + const searchedPosts = postFuse.search(searchStr).map((item) => item.item); const searchedCollections = collectionFuse .search(searchStr) .map((item) => item.item); - res.send({ + + const searchedUnicorns: Record = {}; + for (const post of searchedPosts) { + for (const id of post.authors) searchedUnicorns[id] = unicorns[id]; + } + for (const collection of searchedCollections) { + for (const id of collection.authors) searchedUnicorns[id] = unicorns[id]; + } + + return { + unicorns: searchedUnicorns, posts: searchedPosts, totalPosts: searchedPosts.length, collections: searchedCollections, totalCollections: searchedCollections.length, - }); + }; +} + +export default async (req: VercelRequest, res: VercelResponse) => { + const response = runQuery(req); + res.send(response); }; diff --git a/build-scripts/search-index.ts b/build-scripts/search-index.ts index 29a8df10..27371eaa 100644 --- a/build-scripts/search-index.ts +++ b/build-scripts/search-index.ts @@ -2,7 +2,7 @@ import Fuse from "fuse.js"; import * as fs from "fs"; import * as path from "path"; import * as api from "utils/api"; -import { PostInfo, CollectionInfo } from "types/index"; +import { PostInfo, CollectionInfo, UnicornInfo } from "types/index"; const posts = api.getPostsByLang("en"); const collections = api.getCollectionsByLang("en"); @@ -87,5 +87,18 @@ const createCollectionIndex = () => { const postIndex = createPostIndex(); const collectionIndex = createCollectionIndex(); -const json = JSON.stringify({ postIndex, posts, collectionIndex, collections }); +const unicorns: Record = api + .getUnicornsByLang("en") + .reduce((obj, unicorn) => { + obj[unicorn.id] = unicorn; + return obj; + }, {}); + +const json = JSON.stringify({ + postIndex, + posts, + collectionIndex, + collections, + unicorns, +}); fs.writeFileSync(path.resolve(process.cwd(), "./api/searchIndex.json"), json); diff --git a/src/pages/collections/framework-field-guide/index.astro b/src/pages/collections/framework-field-guide/index.astro index 30dbf6ac..e523e980 100644 --- a/src/pages/collections/framework-field-guide/index.astro +++ b/src/pages/collections/framework-field-guide/index.astro @@ -33,10 +33,10 @@ import YourGuide from "src/views/collections/framework-field-guide/segments/your import CodeBlock from "src/views/collections/framework-field-guide/segments/code-block.astro"; import TheRestContainer from "src/views/collections/framework-field-guide/segments/the-rest-container.astro"; import Pricing from "src/views/collections/framework-field-guide/segments/pricing.astro"; -import { unicorns } from "utils/data"; import SignUp from "src/views/collections/framework-field-guide/segments/sign-up.astro"; import WhyLearnAllThree from "src/views/collections/framework-field-guide/segments/why-learn-all-three.astro"; import WhatDoIGet from "src/views/collections/framework-field-guide/segments/what-do-i-get.astro"; +import { getUnicornById } from "utils/api"; --- @@ -45,7 +45,7 @@ import WhatDoIGet from "src/views/collections/framework-field-guide/segments/wha pathName={`/collections/framework-field-guide`} title={"The Framework Field Guide"} description={"A practical and free way to teach Angular, React, and Vue all at once, so you can choose the right tool for the job and learn the underlying concepts in depth."} - unicornsData={[unicorns.find((uni) => uni.id === "crutchcorn")]} + unicornsData={[getUnicornById("crutchcorn", "en")]} publishedTime={"2022-12-01T13:45:00.284Z"} type={"book"} shareImage={"/custom-content/collections/framework-field-guide/framework_field_guide_social.png"} diff --git a/src/views/explore/page.astro b/src/views/explore/page.astro index 86a0219f..ad376806 100644 --- a/src/views/explore/page.astro +++ b/src/views/explore/page.astro @@ -1,17 +1,26 @@ --- -import { PostInfo } from "types/index"; +import { Languages, PostInfo } from "types/index"; import { PostCardGrid } from "src/components/post-card/post-card-grid"; import { getUnicornProfilePicMap } from "utils/get-unicorn-profile-pic-map"; import { Pagination } from "components/pagination/pagination"; import { Page } from "astro"; +import * as api from "utils/api"; export interface PageProps { posts: PostInfo[]; page: Pick, "currentPage" | "lastPage">; + locale: Languages; } -const { posts, page } = Astro.props as PageProps; +const { posts, page, locale } = Astro.props as PageProps; const unicornProfilePicMap = await getUnicornProfilePicMap(); + +const postAuthors = new Map( + [...new Set(posts.flatMap((p) => p.authors))].map((id) => [ + id, + api.getUnicornById(id, locale), + ]), +); ---

Posts

@@ -20,6 +29,7 @@ const unicornProfilePicMap = await getUnicornProfilePicMap(); expanded={true} aria-label="List of posts" postsToDisplay={posts} + postAuthors={postAuthors} unicornProfilePicMap={unicornProfilePicMap} /> diff --git a/src/views/search/components/filter-display.tsx b/src/views/search/components/filter-display.tsx index ac283362..cc70d9cf 100644 --- a/src/views/search/components/filter-display.tsx +++ b/src/views/search/components/filter-display.tsx @@ -1,6 +1,6 @@ import { ProfilePictureMap } from "utils/get-unicorn-profile-pic-map"; import { PostInfo } from "types/PostInfo"; -import { ExtendedCollectionInfo } from "types/CollectionInfo"; +import { CollectionInfo } from "types/CollectionInfo"; import { useMemo } from "preact/hooks"; import { UnicornInfo } from "types/UnicornInfo"; import { CSSProperties } from "preact/compat"; @@ -15,7 +15,8 @@ interface FilterDisplayProps { unicornProfilePicMap: ProfilePictureMap; posts: PostInfo[]; - collections: ExtendedCollectionInfo[]; + collections: CollectionInfo[]; + unicornsMap: Map, selectedTags: string[]; setSelectedTags: (tags: string[]) => void; selectedAuthorIds: string[]; @@ -33,6 +34,7 @@ interface FilterDisplayProps { export const FilterDisplay = ({ unicornProfilePicMap, collections, + unicornsMap, posts, sort, setSort, @@ -80,27 +82,14 @@ export const FilterDisplay = ({ const authors = useMemo(() => { const postAuthorIdToPostNumMap = new Map(); - const authors: UnicornInfo[] = []; posts.forEach((post) => { - post.authorsMeta.forEach((author) => { - authors.push(author); - - const numPosts = postAuthorIdToPostNumMap.get(author.id) || 0; - postAuthorIdToPostNumMap.set(author.id, numPosts + 1); + post.authors.forEach((author) => { + const numPosts = postAuthorIdToPostNumMap.get(author) || 0; + postAuthorIdToPostNumMap.set(author, numPosts + 1); }); }); - collections.forEach((collection) => { - collection.authorsMeta.forEach((author) => { - authors.push(author); - }); - }); - - const uniqueAuthors = new Map(); - authors.forEach((author) => { - uniqueAuthors.set(author.id, author); - }); - return Array.from(uniqueAuthors.values()) + return Array.from(unicornsMap.values()) .sort((a, b) => a.name.localeCompare(b.name)) .map((author) => ({ ...author, diff --git a/src/views/search/search-page.spec.tsx b/src/views/search/search-page.spec.tsx index 073ea62f..ba6a9b15 100644 --- a/src/views/search/search-page.spec.tsx +++ b/src/views/search/search-page.spec.tsx @@ -9,7 +9,8 @@ import { queryByText, getByTestId, } from "@testing-library/preact"; -import SearchPage, { ServerReturnType } from "./search-page"; +import SearchPage from "./search-page"; +import type { ServerReturnType } from "./types"; import { rest } from "msw"; import { setupServer } from "msw/node"; import { MockCanonicalPost, MockPost } from "../../../__mocks__/data/mock-post"; @@ -67,6 +68,7 @@ describe("Search page", () => { test("Should show search results for posts", async () => { mockFetch(() => ({ + unicorns: {}, posts: [MockPost], totalPosts: 1, totalCollections: 0, @@ -84,6 +86,7 @@ describe("Search page", () => { test("Should show search results for collections", async () => { mockFetch(() => ({ + unicorns: {}, posts: [], totalPosts: 0, totalCollections: 1, @@ -120,6 +123,7 @@ describe("Search page", () => { test("Should show 'nothing found'", async () => { mockFetch(() => ({ + unicorns: {}, posts: [], totalPosts: 0, totalCollections: 0, @@ -139,6 +143,7 @@ describe("Search page", () => { test("Remove collections header when none found", async () => { mockFetch(() => ({ + unicorns: {}, posts: [MockPost], totalPosts: 1, totalCollections: 0, @@ -159,6 +164,7 @@ describe("Search page", () => { test("Remove posts header when none found", async () => { mockFetch(() => ({ + unicorns: {}, posts: [], totalPosts: 0, totalCollections: 1, @@ -179,6 +185,7 @@ describe("Search page", () => { test("Filter by tag works on desktop sidebar", async () => { mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, tags: ["Angular"], title: "One blog post" }, { ...MockCanonicalPost, tags: [], title: "Two blog post" }, @@ -210,6 +217,7 @@ describe("Search page", () => { test("Filter by author works on desktop sidebar", async () => { mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, @@ -251,6 +259,7 @@ describe("Search page", () => { test("Filter by content type work on radio group buttons", async () => { mockFetch(() => ({ + unicorns: {}, posts: [{ ...MockPost, title: "One blog post" }], totalPosts: 1, totalCollections: 1, @@ -293,6 +302,7 @@ describe("Search page", () => { global.innerWidth = 2000; mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, @@ -352,6 +362,7 @@ describe("Search page", () => { test("Sort by date works on mobile radio group buttons", async () => { global.innerWidth = 500; mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, @@ -411,6 +422,7 @@ describe("Search page", () => { test("Pagination - Changing pages to page 2 shows second page of results", async () => { // 6 posts per page mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, slug: `blog-post-1`, title: "One blog post" }, { ...MockPost, slug: `blog-post-2`, title: "Two blog post" }, @@ -473,6 +485,7 @@ describe("Search page", () => { global.innerWidth = 2000; // 6 posts per page mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, @@ -603,6 +616,7 @@ describe("Search page", () => { // Search page, sort order, etc test("Make sure that initial search props are not thrown away", async () => { mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, @@ -762,6 +776,7 @@ describe("Search page", () => { global.innerWidth = 2000; mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, @@ -830,6 +845,7 @@ describe("Search page", () => { test("Make sure that re-searches reset page to 1 and preserve tags, authors, etc", async () => { mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, @@ -981,6 +997,7 @@ describe("Search page", () => { test("Make sure that re-searches to empty string reset page, tags, authors, etc", async () => { mockFetch(() => ({ + unicorns: {}, posts: [ { ...MockPost, @@ -1132,6 +1149,7 @@ describe("Search page", () => { test("Back button should show last query", async () => { mockFetch(() => ({ + unicorns: {}, posts: [], totalPosts: 0, totalCollections: 0, diff --git a/src/views/search/search-page.tsx b/src/views/search/search-page.tsx index 308efcef..9b08b63e 100644 --- a/src/views/search/search-page.tsx +++ b/src/views/search/search-page.tsx @@ -21,7 +21,6 @@ import style from "./search-page.module.scss"; import { PostCardGrid } from "components/post-card/post-card-grid"; import { SubHeader } from "components/subheader/subheader"; import { Fragment } from "preact"; -import { ExtendedCollectionInfo } from "types/CollectionInfo"; import { CollectionCard } from "components/collection-card/collection-card"; import { FilterDisplay } from "./components/filter-display"; import { useElementSize } from "../../hooks/use-element-size"; @@ -43,6 +42,8 @@ import { import { debounce } from "utils/debounce"; import { SortType } from "./components/types"; import { SearchResultCount } from "./components/search-result-count"; +import { ServerReturnType } from "./types"; +import { CollectionInfo } from "types/CollectionInfo"; const DEFAULT_SORT = "relevance"; const DEFAULT_CONTENT_TO_DISPLAY = "all"; @@ -53,13 +54,6 @@ interface SearchPageProps { const MAX_POSTS_PER_PAGE = 6; -export interface ServerReturnType { - posts: PostInfo[]; - totalPosts: number; - collections: ExtendedCollectionInfo[]; - totalCollections: number; -} - function SearchPageBase({ unicornProfilePicMap }: SearchPageProps) { const { urlParams, pushState } = useSearchParams(); @@ -141,11 +135,12 @@ function SearchPageBase({ unicornProfilePicMap }: SearchPageProps) { }, queryKey: ["search", debouncedSearch], initialData: { + unicorns: {}, posts: [], totalPosts: 0, collections: [], totalCollections: 0, - } as ServerReturnType, + }, refetchOnWindowFocus: false, retry: false, enabled, @@ -271,7 +266,7 @@ function SearchPageBase({ unicornProfilePicMap }: SearchPageProps) { }); }, [data, page, sort, selectedUnicorns, selectedTags]); - const filteredAndSortedCollections = useMemo(() => { + const filteredAndSortedCollections: CollectionInfo[] = useMemo(() => { const collections = [...data.collections]; if (sort && sort !== "relevance") { @@ -365,6 +360,7 @@ function SearchPageBase({ unicornProfilePicMap }: SearchPageProps) { unicornProfilePicMap={unicornProfilePicMap} collections={data.collections} posts={data.posts} + unicornsMap={new Map(Object.entries(data.unicorns))} selectedTags={selectedTags} setSelectedTags={setSelectedTags} selectedAuthorIds={selectedUnicorns} @@ -488,6 +484,9 @@ function SearchPageBase({ unicornProfilePicMap }: SearchPageProps) { data.unicorns[id])} + // TODO: post count should be sourced from the collection info + posts={[]} headingTag="h3" /> @@ -509,6 +508,7 @@ function SearchPageBase({ unicornProfilePicMap }: SearchPageProps) { diff --git a/src/views/search/types.ts b/src/views/search/types.ts new file mode 100644 index 00000000..03f01bef --- /dev/null +++ b/src/views/search/types.ts @@ -0,0 +1,11 @@ +import { CollectionInfo } from "types/CollectionInfo"; +import { PostInfo } from "types/PostInfo"; +import { UnicornInfo } from "types/UnicornInfo"; + +export interface ServerReturnType { + unicorns: Record; + posts: PostInfo[]; + totalPosts: number; + collections: CollectionInfo[]; + totalCollections: number; +}