diff --git a/adminSiteServer/mockSiteRouter.ts b/adminSiteServer/mockSiteRouter.ts index 574a1c64d01..60202554adb 100644 --- a/adminSiteServer/mockSiteRouter.ts +++ b/adminSiteServer/mockSiteRouter.ts @@ -570,6 +570,15 @@ getPlainRouteWithROTransaction( } ) +getPlainRouteWithROTransaction( + mockSiteRouter, + "/topicTagGraph.json", + async (req, res, trx) => { + const headerMenu = await db.generateTopicTagGraph(trx) + res.send(headerMenu) + } +) + getPlainRouteWithROTransaction(mockSiteRouter, "/*", async (req, res, trx) => { // Remove leading and trailing slashes const slug = req.path.replace(/^\/|\/$/g, "") diff --git a/baker/SiteBaker.tsx b/baker/SiteBaker.tsx index c422adcaa4e..026de724d6b 100644 --- a/baker/SiteBaker.tsx +++ b/baker/SiteBaker.tsx @@ -1055,6 +1055,13 @@ export class SiteBaker { `rm -rf ${this.bakedSiteDir}/assets && cp -r ${BASE_DIR}/dist/assets ${this.bakedSiteDir}/assets` ) + await fs.writeFile( + `${this.bakedSiteDir}/topicTagGraph.json`, + await db + .generateTopicTagGraph(trx) + .then((nav) => JSON.stringify(nav)) + ) + // The `assets-admin` folder is optional; don't fail if it doesn't exist await execWrapper( `rm -rf ${this.bakedSiteDir}/assets-admin && (cp -r ${BASE_DIR}/dist/assets-admin ${this.bakedSiteDir}/assets-admin || true)` diff --git a/db/db.ts b/db/db.ts index ab9aa2e1ada..1f593178294 100644 --- a/db/db.ts +++ b/db/db.ts @@ -30,6 +30,7 @@ import { MinimalExplorerInfo, DbEnrichedImage, DbEnrichedImageWithUserId, + TagGraphRoot, } from "@ourworldindata/types" import { groupBy, uniq } from "lodash" import { gdocFromJSON } from "./model/Gdoc/GdocFactory.js" @@ -504,6 +505,7 @@ export async function getFlatTagGraph(knex: KnexReadonlyTransaction): Promise< tg.parentId, tg.childId, tg.weight, + t.slug, t.name, p.slug IS NOT NULL AS isTopic FROM @@ -806,3 +808,43 @@ export function getImageUsage(trx: KnexReadonlyTransaction): Promise< ) ) } + +export async function generateTopicTagGraph( + knex: KnexReadonlyTransaction +): Promise { + const { __rootId, ...parents } = await getFlatTagGraph(knex) + + const tagGraphTopicsOnly = Object.entries(parents).reduce( + (acc: FlatTagGraph, [parentId, children]) => { + acc[Number(parentId)] = children.filter((child) => { + if (child.parentId === __rootId) return true + return child.isTopic + }) + return acc + }, + {} as FlatTagGraph + ) + + return createTagGraph(tagGraphTopicsOnly, __rootId) +} + +export const getUniqueTopicCount = ( + trx: KnexReadonlyTransaction +): Promise => { + const count = knexRawFirst<{ count: number }>( + trx, + `-- sql + SELECT COUNT(DISTINCT(t.slug)) + FROM tags t + LEFT JOIN posts_gdocs p ON t.slug = p.slug + WHERE t.slug IS NOT NULL AND p.published IS TRUE` + ) + .then((res) => (res ? res.count : 0)) + .catch((e) => { + console.error("Failed to get unique topic count", e) + throw e + }) + // throw on count == 0 also + if (!count) throw new Error("Failed to get unique topic count") + return count +} diff --git a/db/model/Gdoc/GdocHomepage.ts b/db/model/Gdoc/GdocHomepage.ts index bf51ec4fbb7..04951e2b2a1 100644 --- a/db/model/Gdoc/GdocHomepage.ts +++ b/db/model/Gdoc/GdocHomepage.ts @@ -12,7 +12,6 @@ import { OwidGdocBaseInterface, OwidGdocHomepageMetadata, } from "@ourworldindata/types" -import { getUniqueTopicCount } from "../../../site/gdocs/utils.js" import { getLatestDataInsights } from "./GdocFactory.js" export class GdocHomepage @@ -69,7 +68,8 @@ export class GdocHomepage this.homepageMetadata = { chartCount: grapherCount + nonGrapherExplorerViewCount, - topicCount: getUniqueTopicCount(), + topicCount: await db.getUniqueTopicCount(knex), + tagGraph: await db.generateTopicTagGraph(knex), } const { dataInsights, imageMetadata } = diff --git a/db/model/Post.ts b/db/model/Post.ts index d8a7788a2a5..72eb07ed98f 100644 --- a/db/model/Post.ts +++ b/db/model/Post.ts @@ -6,7 +6,6 @@ import { parsePostWpApiSnapshot, FullPost, JsonError, - CategoryWithEntries, WP_PostType, FilterFnPostRestApi, PostRestApi, @@ -40,7 +39,6 @@ import { CLOUDFLARE_IMAGES_URL, } from "../../settings/clientSettings.js" import { BLOG_SLUG } from "../../settings/serverSettings.js" -import { SiteNavigationStatic } from "../../site/SiteConstants.js" import { decodeHTML } from "entities" import { getAndLoadListedGdocPosts } from "./Gdoc/GdocFactory.js" @@ -185,22 +183,9 @@ export const getFullPostByIdFromSnapshot = async ( return getFullPost(trx, postEnriched.wpApiSnapshot) } -// TODO: I suggest that in the place where we define SiteNavigationStatic we create a Set with all the leaves and -// then this one becomes a simple lookup in the set. Probably nicest to do the set creation as a memoized function. +// This function used to be more complicated, but now the only citable WP post is the COVID page export const isPostSlugCitable = (slug: string): boolean => { - const entries = SiteNavigationStatic.categories - return entries.some((category) => { - return ( - category.entries.some((entry) => entry.slug === slug) || - (category.subcategories ?? []).some( - (subcategory: CategoryWithEntries) => { - return subcategory.entries.some( - (subCategoryEntry) => subCategoryEntry.slug === slug - ) - } - ) - ) - }) + return slug === "coronavirus" } export const getPostsFromSnapshots = async ( diff --git a/packages/@ourworldindata/types/src/domainTypes/ContentGraph.ts b/packages/@ourworldindata/types/src/domainTypes/ContentGraph.ts index 34cf5a8fd8c..e04af7636f5 100644 --- a/packages/@ourworldindata/types/src/domainTypes/ContentGraph.ts +++ b/packages/@ourworldindata/types/src/domainTypes/ContentGraph.ts @@ -28,6 +28,7 @@ export type FlatTagGraphNode = Pick & { isTopic: boolean parentId: number childId: number + slug: string | null } export type FlatTagGraph = Record diff --git a/packages/@ourworldindata/types/src/gdocTypes/Gdoc.ts b/packages/@ourworldindata/types/src/gdocTypes/Gdoc.ts index a6f35022ea3..aa59512e0f9 100644 --- a/packages/@ourworldindata/types/src/gdocTypes/Gdoc.ts +++ b/packages/@ourworldindata/types/src/gdocTypes/Gdoc.ts @@ -13,6 +13,7 @@ import { } from "./ArchieMlComponents.js" import { MinimalTag } from "../dbTypes/Tags.js" import { DbEnrichedLatestWork } from "../domainTypes/Author.js" +import { TagGraphRoot } from "../domainTypes/ContentGraph.js" export enum OwidGdocPublicationContext { unlisted = "unlisted", @@ -178,6 +179,7 @@ export interface OwidGdocHomepageContent { export interface OwidGdocHomepageMetadata { chartCount?: number topicCount?: number + tagGraph?: TagGraphRoot } export interface OwidGdocHomepageInterface extends OwidGdocBaseInterface { diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index cd2978a218b..87b235f874e 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -1966,6 +1966,13 @@ export function createTagGraph( return recursivelySetChildren(tagGraph) as TagGraphRoot } +export const getAllTopicsInArea = (area: TagGraphNode): TagGraphNode[] => { + return [ + ...area.children, + ...area.children.flatMap((child) => getAllTopicsInArea(child)), + ] +} + export function formatInlineList( array: unknown[], connector: "and" | "or" = "and" diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index 1a98410e6ce..12a68e304ba 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -124,6 +124,7 @@ export { commafyNumber, isFiniteWithGuard, createTagGraph, + getAllTopicsInArea, formatInlineList, lazy, getParentVariableIdFromChartConfig, diff --git a/site/SiteMobileCategory.tsx b/site/SiteMobileArea.tsx similarity index 57% rename from site/SiteMobileCategory.tsx rename to site/SiteMobileArea.tsx index 01199370e2e..acb809b7c9d 100644 --- a/site/SiteMobileCategory.tsx +++ b/site/SiteMobileArea.tsx @@ -1,17 +1,16 @@ import { useEffect, useRef } from "react" -import { CategoryWithEntries } from "@ourworldindata/utils" -import { SiteNavigationToggle } from "./SiteNavigationToggle.js" import { SiteNavigationTopic } from "./SiteNavigationTopic.js" -import { allTopicsInCategory } from "./gdocs/utils.js" +import { TagGraphNode, getAllTopicsInArea } from "@ourworldindata/utils" +import { SiteNavigationToggle } from "./SiteNavigationToggle.js" -export const SiteMobileCategory = ({ - category, +export const SiteMobileArea = ({ + area, isActive, - toggleCategory, + toggleArea, }: { - category: CategoryWithEntries + area: TagGraphNode isActive: boolean - toggleCategory: (category: CategoryWithEntries) => void + toggleArea: (category: TagGraphNode) => void }) => { const categoryRef = useRef(null) @@ -22,20 +21,14 @@ export const SiteMobileCategory = ({ }, [isActive]) return ( -
  • +
  • toggleCategory(category)} + onToggle={() => toggleArea(area)} dropdown={
      - {allTopicsInCategory(category).map((topic) => ( + {getAllTopicsInArea(area).map((topic) => ( - {category.name} + {area.name} ) diff --git a/site/SiteMobileMenu.tsx b/site/SiteMobileMenu.tsx index f335b6d458e..7b3a746e58e 100644 --- a/site/SiteMobileMenu.tsx +++ b/site/SiteMobileMenu.tsx @@ -1,31 +1,30 @@ import { useState } from "react" -import { CategoryWithEntries } from "@ourworldindata/utils" +import { TagGraphNode, TagGraphRoot } from "@ourworldindata/utils" import classnames from "classnames" import { SiteNavigationToggle } from "./SiteNavigationToggle.js" import { Menu } from "./SiteConstants.js" import { SiteAbout } from "./SiteAbout.js" import { SiteResources } from "./SiteResources.js" -import { SiteMobileCategory } from "./SiteMobileCategory.js" +import { SiteMobileArea } from "./SiteMobileArea.js" export const SiteMobileMenu = ({ - topics, + tagGraph, menu, toggleMenu, className, }: { - topics: CategoryWithEntries[] + tagGraph: TagGraphRoot | null menu: Menu | null toggleMenu: (menu: Menu) => void className?: string }) => { - const [activeCategory, setActiveCategory] = - useState(null) + const [activeArea, setActiveArea] = useState(null) - const toggleCategory = (category: CategoryWithEntries) => { - if (activeCategory === category) { - setActiveCategory(null) + const toggleArea = (area: TagGraphNode) => { + if (activeArea === area) { + setActiveArea(null) } else { - setActiveCategory(category) + setActiveArea(area) } } @@ -35,12 +34,12 @@ export const SiteMobileMenu = ({
    • Browse by topic
        - {topics.map((category) => ( - ( + ))}
      diff --git a/site/SiteNavigation.tsx b/site/SiteNavigation.tsx index 92d79514b77..6a248085fe4 100644 --- a/site/SiteNavigation.tsx +++ b/site/SiteNavigation.tsx @@ -11,6 +11,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { SiteNavigationTopics } from "./SiteNavigationTopics.js" import { SiteLogos } from "./SiteLogos.js" import { SiteAbout } from "./SiteAbout.js" +import { TagGraphRoot } from "@ourworldindata/utils" import { SiteResources } from "./SiteResources.js" import { SiteSearchNavigation } from "./SiteSearchNavigation.js" import { SiteMobileMenu } from "./SiteMobileMenu.js" @@ -18,7 +19,7 @@ import { SiteNavigationToggle } from "./SiteNavigationToggle.js" import classnames from "classnames" import { useTriggerOnEscape } from "./hooks.js" import { AUTOCOMPLETE_CONTAINER_ID } from "./search/Autocomplete.js" -import { Menu, SiteNavigationStatic } from "./SiteConstants.js" +import { Menu } from "./SiteConstants.js" // Note: tranforming the flag from an env string to a boolean in // clientSettings.ts is convoluted due to the two-pass SSR/Vite build process. @@ -35,6 +36,16 @@ export const SiteNavigation = ({ }) => { const [menu, setActiveMenu] = useState(null) const [query, setQuery] = useState("") + const [tagGraph, setTagGraph] = useState(null) + + useEffect(() => { + const fetchTagGraph = async () => { + const response = await fetch("/topicTagGraph.json") + const tagGraph = await response.json() + setTagGraph(tagGraph) + } + if (!tagGraph) fetchTagGraph().catch(console.error) + }, [tagGraph, setTagGraph]) const isActiveMobileMenu = menu !== null && @@ -111,7 +122,7 @@ export const SiteNavigation = ({ } @@ -131,9 +142,7 @@ export const SiteNavigation = ({ dropdown={ } diff --git a/site/SiteNavigationTopic.tsx b/site/SiteNavigationTopic.tsx index 62b8e4021b2..ba5900eaefd 100644 --- a/site/SiteNavigationTopic.tsx +++ b/site/SiteNavigationTopic.tsx @@ -1,10 +1,10 @@ -import { EntryMeta } from "@ourworldindata/utils" +import { TagGraphNode } from "@ourworldindata/utils" -export const SiteNavigationTopic = ({ topic }: { topic: EntryMeta }) => { +export const SiteNavigationTopic = ({ topic }: { topic: TagGraphNode }) => { return (
    • - {topic.title} + {topic.name}
    • ) diff --git a/site/SiteNavigationTopics.tsx b/site/SiteNavigationTopics.tsx index 7585cc40e70..d985b0613d7 100644 --- a/site/SiteNavigationTopics.tsx +++ b/site/SiteNavigationTopics.tsx @@ -1,23 +1,26 @@ -import { useLayoutEffect, useState } from "react" -import * as React from "react" -import { CategoryWithEntries } from "@ourworldindata/utils" +import React, { useState, useLayoutEffect } from "react" +import { + TagGraphNode, + TagGraphRoot, + getAllTopicsInArea, +} from "@ourworldindata/utils" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { faArrowRight } from "@fortawesome/free-solid-svg-icons" import classnames from "classnames" import { SiteNavigationTopic } from "./SiteNavigationTopic.js" -import { allTopicsInCategory } from "./gdocs/utils.js" export const SiteNavigationTopics = ({ - topics, + tagGraph, onClose, className, }: { - topics: CategoryWithEntries[] + tagGraph: TagGraphRoot | null onClose: () => void className?: string }) => { - const [activeCategory, setActiveCategory] = - useState(topics[0]) + const [activeCategory, setActiveCategory] = useState( + tagGraph?.children[0] || null + ) const [numTopicColumns, setNumTopicColumns] = useState(1) @@ -25,7 +28,7 @@ export const SiteNavigationTopics = ({ // using useLayoutEffect to avoid a flash of the wrong number of columns when switching categories useLayoutEffect(() => { if (activeCategory) { - const topics = allTopicsInCategory(activeCategory) + const topics = getAllTopicsInArea(activeCategory) const numColumns = Math.ceil(topics.length / 10) setNumTopicColumns(numColumns) } @@ -35,7 +38,7 @@ export const SiteNavigationTopics = ({ e.stopPropagation() } - return topics.length > 0 ? ( + return tagGraph?.children ? (
      Browse by topic
        - {topics.map((category) => ( + {tagGraph.children.map((category) => (
      diff --git a/site/gdocs/pages/Homepage.tsx b/site/gdocs/pages/Homepage.tsx index daf406dd9ad..9d5420bca00 100644 --- a/site/gdocs/pages/Homepage.tsx +++ b/site/gdocs/pages/Homepage.tsx @@ -1,19 +1,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" - import * as React from "react" import { NewsletterSubscriptionContext } from "../../newsletter.js" import { NewsletterSubscriptionForm } from "../../NewsletterSubscription.js" import { ArticleBlocks } from "../components/ArticleBlocks.js" -import { - CategoryWithEntries, - EntryMeta, - OwidGdocHomepageContent, -} from "@ourworldindata/types" -import { - SiteNavigationStatic, - RSS_FEEDS, - SOCIALS, -} from "../../SiteConstants.js" +import { OwidGdocHomepageContent } from "@ourworldindata/types" +import { RSS_FEEDS, SOCIALS } from "../../SiteConstants.js" +import { getAllTopicsInArea } from "@ourworldindata/utils" +import { AttachmentsContext } from "../AttachmentsContext.js" export interface HomepageProps { content: OwidGdocHomepageContent @@ -60,29 +53,18 @@ const SocialSection = () => { ) } -type FlattenedCategory = EntryMeta & { isSubcategoryHeading?: boolean } +const AllTopicsSection = () => { + const { homepageMetadata } = React.useContext(AttachmentsContext) + if (!homepageMetadata) return null + const { tagGraph } = homepageMetadata + if (!tagGraph) return null -// We have to flatten the categories because it's not possible to inline wrap nested
        elements the way we'd like them to -const flattenCategories = (categories: CategoryWithEntries[]) => - categories.map((category) => { - // First show any top-level entries that exist on the category - const flattened: FlattenedCategory[] = [...category.entries] - category.subcategories?.forEach((subcategory) => { - // Then for each subcategory, show the subcategory heading and then its entries - flattened.push({ - title: subcategory.name, - slug: subcategory.slug, - isSubcategoryHeading: true, - }) - flattened.push(...subcategory.entries) - }) - return { entries: flattened, name: category.name } - }) + // We have to flatten the areas because we can't nest
          elements and have them render correctly + const flattenedAreas = tagGraph.children.map((area) => ({ + ...area, + children: getAllTopicsInArea(area), + })) -const AllTopicsSection = () => { - const flattenedCategories = flattenCategories( - SiteNavigationStatic.categories - ) return (
          {

          All our data, research, and writing — topic by topic.

          - {flattenedCategories.map((category) => ( + {flattenedAreas.map((area) => (

          - {category.name} + {area.name}

            - {category.entries.map( - ({ slug, title, isSubcategoryHeading }) => - isSubcategoryHeading ? ( -
          • - {title}: -
          • - ) : ( -
          • - {title} -
          • - ) - )} + {area.children.map(({ slug, name }) => ( +
          • + {name} +
          • + ))}
          ))} diff --git a/site/runSiteNavigation.tsx b/site/runSiteNavigation.tsx new file mode 100644 index 00000000000..40e3ed37996 --- /dev/null +++ b/site/runSiteNavigation.tsx @@ -0,0 +1,23 @@ +import ReactDOM from "react-dom" +import { getOwidGdocFromJSON, OwidGdocType } from "@ourworldindata/utils" +import { SiteNavigation } from "./SiteNavigation" + +export const runSiteNavigation = ( + baseUrl: string, + hideDonationFlag?: boolean +) => { + // Used to determine whether or not to show the searchbar in the header + let isOnHomepage = false + if (window._OWID_GDOC_PROPS) { + const props = getOwidGdocFromJSON(window._OWID_GDOC_PROPS) + isOnHomepage = props?.content?.type === OwidGdocType.Homepage + } + ReactDOM.render( + , + document.querySelector(".site-navigation-root") + ) +}