add unicorn data to search API response, fix type errors

This commit is contained in:
James Fenn
2023-10-19 01:59:12 -04:00
parent 84d85ceb69
commit aa3ebfbcea
12 changed files with 123 additions and 89 deletions

View File

@@ -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",

View File

@@ -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",
};

View File

@@ -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",
};

View File

@@ -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",

View File

@@ -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<ExtendedPostInfo>(
const postFuse = new Fuse<PostInfo>(
posts,
{
threshold: 0.3,
@@ -23,7 +24,7 @@ const postFuse = new Fuse<ExtendedPostInfo>(
postIndex,
);
const collectionFuse = new Fuse(
const collectionFuse = new Fuse<CollectionInfo>(
collections,
{
threshold: 0.3,
@@ -34,35 +35,53 @@ const collectionFuse = new Fuse(
collectionIndex,
);
export default async (req: VercelRequest, res: VercelResponse) => {
const unicorns: Record<string, UnicornInfo> = 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<string, UnicornInfo> = {};
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);
};

View File

@@ -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<string, UnicornInfo> = 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);

View File

@@ -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";
---
<Barebones>
@@ -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"}

View File

@@ -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<Page<any>, "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),
]),
);
---
<h1 class="visually-hidden">Posts</h1>
@@ -20,6 +29,7 @@ const unicornProfilePicMap = await getUnicornProfilePicMap();
expanded={true}
aria-label="List of posts"
postsToDisplay={posts}
postAuthors={postAuthors}
unicornProfilePicMap={unicornProfilePicMap}
/>

View File

@@ -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<string, UnicornInfo>,
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<string, number>();
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<string, UnicornInfo>();
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,

View File

@@ -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,

View File

@@ -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) {
<CollectionCard
unicornProfilePicMap={unicornProfilePicMap}
collection={collection}
authors={collection.authors.map(id => data.unicorns[id])}
// TODO: post count should be sourced from the collection info
posts={[]}
headingTag="h3"
/>
</li>
@@ -509,6 +508,7 @@ function SearchPageBase({ unicornProfilePicMap }: SearchPageProps) {
<PostCardGrid
aria-labelledby={"articles-header"}
postsToDisplay={posts}
postAuthors={new Map(Object.entries(data.unicorns))}
postHeadingTag="h3"
unicornProfilePicMap={unicornProfilePicMap}
/>

11
src/views/search/types.ts Normal file
View File

@@ -0,0 +1,11 @@
import { CollectionInfo } from "types/CollectionInfo";
import { PostInfo } from "types/PostInfo";
import { UnicornInfo } from "types/UnicornInfo";
export interface ServerReturnType {
unicorns: Record<string, UnicornInfo>;
posts: PostInfo[];
totalPosts: number;
collections: CollectionInfo[];
totalCollections: number;
}