diff --git a/adminSiteClient/AdminApp.tsx b/adminSiteClient/AdminApp.tsx index e7a579b9bf0..0b3ca18a193 100644 --- a/adminSiteClient/AdminApp.tsx +++ b/adminSiteClient/AdminApp.tsx @@ -20,6 +20,7 @@ import { TestIndexPage } from "./TestIndexPage.js" import { NotFoundPage } from "./NotFoundPage.js" import { PostEditorPage } from "./PostEditorPage.js" import { DeployStatusPage } from "./DeployStatusPage.js" +import { ExplorerTagsPage } from "./ExplorerTagsPage.js" import { SuggestedChartRevisionApproverPage } from "./SuggestedChartRevisionApproverPage.js" import { SuggestedChartRevisionListPage } from "./SuggestedChartRevisionListPage.js" import { SuggestedChartRevisionImportPage } from "./SuggestedChartRevisionImportPage.js" @@ -303,6 +304,11 @@ export class AdminApp extends React.Component<{ path="/deploys" component={DeployStatusPage} /> + ( Explorers +
    +
  • + + Explorer Tags + +
  • +
  • DATA
  • diff --git a/adminSiteClient/ExplorerTagsPage.tsx b/adminSiteClient/ExplorerTagsPage.tsx new file mode 100644 index 00000000000..804f156f37a --- /dev/null +++ b/adminSiteClient/ExplorerTagsPage.tsx @@ -0,0 +1,238 @@ +import React from "react" +import { observer } from "mobx-react" +import { action, computed, observable, runInAction } from "mobx" + +import { AdminLayout } from "./AdminLayout.js" +import { AdminAppContext, AdminAppContextType } from "./AdminAppContext.js" +import { DbChartTagJoin } from "@ourworldindata/utils" +import { + GetAllExplorersRoute, + GetAllExplorersTagsRoute, +} from "../explorer/ExplorerConstants.js" +import { EditableTags } from "./EditableTags.js" +import { ExplorerProgram } from "../explorer/ExplorerProgram.js" +import cx from "classnames" + +type ExplorerWithTags = { + slug: string + tags: DbChartTagJoin[] +} + +@observer +export class ExplorerTagsPage extends React.Component { + static contextType = AdminAppContext + context!: AdminAppContextType + @observable explorersWithTags: ExplorerWithTags[] = [] + @observable explorers: ExplorerProgram[] = [] + @observable tags: DbChartTagJoin[] = [] + @observable newExplorerSlug = "" + @observable newExplorerTags: DbChartTagJoin[] = [] + + componentDidMount() { + this.getData() + } + + // Don't show explorers that already have tags + @computed get filteredExplorers() { + return this.explorers.filter((explorer) => { + return !this.explorersWithTags.find((e) => e.slug === explorer.slug) + }) + } + + render() { + return ( + +
    +

    Explorer tags

    +

    + This page is for managing the tags for each explorer. + Explorer data is currently stored in{" "} + git, + but tags are stored in the database, which means it's + hard to strictly enforce{" "} + + referential integrity. + +

    +

    + If you update an explorer's slug, you'll need to delete + the old row in this table and create a new one for the + new slug. +

    + + + + + + + + + + + {/* Existing Explorers Rows */} + {this.explorersWithTags.map((explorer) => { + const isSlugValid = this.explorers.find( + (e) => e.slug === explorer.slug + ) + + return ( + + + + + + ) + })} + {/* New Explorer Row */} + + + + + + +
    ExplorerTagsControls
    + {explorer.slug} + {isSlugValid ? null : ( +

    + + ❗️ this slug doesn't + match any explorer in + the database - is it for + an explorer that has + been deleted or renamed? + +

    + )} +
    + { + this.saveTags( + explorer.slug, + tags + ) + }} + /> + + +
    + + + { + this.newExplorerTags = tags + }} + /> + + +
    +
    +
    + ) + } + + async getData() { + const [{ tags }, explorersWithTags, explorers] = await Promise.all([ + this.context.admin.getJSON("/api/tags.json") as Promise<{ + tags: DbChartTagJoin[] + }>, + this.context.admin.getJSON(GetAllExplorersTagsRoute) as Promise<{ + explorers: ExplorerWithTags[] + }>, + this.context.admin.getJSON(GetAllExplorersRoute) as Promise<{ + explorers: ExplorerProgram[] + }>, + ]) + runInAction(() => { + this.tags = tags + this.explorersWithTags = explorersWithTags.explorers + this.explorers = explorers.explorers + }) + } + + async saveTags(slug: string, tags: DbChartTagJoin[]) { + const tagIds = tags.map((tag) => tag.id) + await this.context.admin.requestJSON( + `/api/explorer/${slug}/tags`, + { + tagIds, + }, + "POST" + ) + } + + async deleteExplorerTags(slug: string) { + if ( + window.confirm( + `Are you sure you want to delete the tags for "${slug}"?` + ) + ) { + await this.context.admin.requestJSON( + `/api/explorer/${slug}/tags`, + {}, + "DELETE" + ) + await this.getData() + } + } + + @action.bound async saveNewExplorer() { + await this.saveTags(this.newExplorerSlug, this.newExplorerTags) + await this.getData() + this.newExplorerTags = [] + this.newExplorerSlug = "" + } +} diff --git a/adminSiteServer/adminRouter.tsx b/adminSiteServer/adminRouter.tsx index b144559abd8..dd92e267614 100644 --- a/adminSiteServer/adminRouter.tsx +++ b/adminSiteServer/adminRouter.tsx @@ -30,6 +30,7 @@ import { DefaultNewExplorerSlug, EXPLORERS_PREVIEW_ROUTE, GetAllExplorersRoute, + GetAllExplorersTagsRoute, } from "../explorer/ExplorerConstants.js" import { ExplorerProgram, @@ -253,6 +254,12 @@ adminRouter.get(`/${GetAllExplorersRoute}`, async (req, res) => { res.send(await explorerAdminServer.getAllExplorersCommand()) }) +adminRouter.get(`/${GetAllExplorersTagsRoute}`, async (_, res) => { + return res.send({ + explorers: await db.getExplorerTags(db.knexInstance()), + }) +}) + adminRouter.get(`/${EXPLORERS_PREVIEW_ROUTE}/:slug`, async (req, res) => { const slug = slugify(req.params.slug) const filename = slug + EXPLORER_FILE_SUFFIX @@ -275,8 +282,6 @@ adminRouter.get("/datapage-preview/:id", async (req, res) => { const variableId = expectInt(req.params.id) const variableMetadata = await getVariableMetadata(variableId) if (!variableMetadata) throw new JsonError("No such variable", 404) - const publishedExplorersBySlug = - await explorerAdminServer.getAllPublishedExplorersBySlugCached() res.send( await renderDataPageV2({ @@ -284,7 +289,6 @@ adminRouter.get("/datapage-preview/:id", async (req, res) => { variableMetadata, isPreviewing: true, useIndicatorGrapherConfigs: true, - publishedExplorersBySlug, }) ) }) @@ -293,16 +297,7 @@ adminRouter.get("/grapher/:slug", async (req, res) => { const entity = await Chart.getBySlug(req.params.slug) if (!entity) throw new JsonError("No such chart", 404) - const explorerAdminServer = new ExplorerAdminServer(GIT_CMS_DIR) - const publishedExplorersBySlug = - await explorerAdminServer.getAllPublishedExplorersBySlug() - - res.send( - await renderPreviewDataPageOrGrapherPage( - entity.config, - publishedExplorersBySlug - ) - ) + res.send(await renderPreviewDataPageOrGrapherPage(entity.config)) }) const gitCmsServer = new GitCmsServer({ diff --git a/adminSiteServer/apiRouter.ts b/adminSiteServer/apiRouter.ts index 9184c5688ba..92d637ff4a4 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -77,7 +77,6 @@ import { DeployQueueServer } from "../baker/DeployQueueServer.js" import { FunctionalRouter } from "./FunctionalRouter.js" import { escape } from "mysql" import Papa from "papaparse" -import { ExplorerAdminServer } from "../explorerAdminServer/ExplorerAdminServer.js" import { postsTable, setTagsForPost, @@ -92,12 +91,10 @@ import { dataSource } from "../db/dataSource.js" import { createGdocAndInsertOwidGdocPostContent } from "../db/model/Gdoc/archieToGdoc.js" import { Link } from "../db/model/Link.js" import { In } from "typeorm" -import { GIT_CMS_DIR } from "../gitCms/GitCmsConstants.js" import { logErrorAndMaybeSendToBugsnag } from "../serverUtils/errorLog.js" import { GdocFactory } from "../db/model/Gdoc/GdocFactory.js" const apiRouter = new FunctionalRouter() -const explorerAdminServer = new ExplorerAdminServer(GIT_CMS_DIR) // Call this to trigger build and deployment of static charts on change const triggerStaticBuild = async (user: CurrentUser, commitMessage: string) => { @@ -2423,14 +2420,7 @@ apiRouter.get("/gdocs/:id", async (req, res) => { | undefined try { - const publishedExplorersBySlug = - await explorerAdminServer.getAllPublishedExplorersBySlugCached() - - const gdoc = await GdocFactory.load( - id, - publishedExplorersBySlug, - contentSource - ) + const gdoc = await GdocFactory.load(id, contentSource) if (!gdoc.published) { await gdoc.save() @@ -2452,24 +2442,22 @@ apiRouter.get("/gdocs/:id", async (req, res) => { apiRouter.put("/gdocs/:id", async (req, res) => { const { id } = req.params const nextGdocJSON: OwidGdocJSON = req.body - const explorers = - await explorerAdminServer.getAllPublishedExplorersBySlugCached() if (isEmpty(nextGdocJSON)) { // Check to see if the gdoc already exists in the database const existingGdoc = await GdocBase.findOneBy({ id }) if (existingGdoc) { - return GdocFactory.load(id, explorers, GdocsContentSource.Gdocs) + return GdocFactory.load(id, GdocsContentSource.Gdocs) } else { - return GdocFactory.create(id, explorers) + return GdocFactory.create(id) } } - const prevGdoc = await GdocFactory.load(id, {}) + const prevGdoc = await GdocFactory.load(id) if (!prevGdoc) throw new JsonError(`No Google Doc with id ${id} found`) const nextGdoc = GdocFactory.fromJSON(nextGdocJSON) - await nextGdoc.loadState(explorers) + await nextGdoc.loadState() // Deleting and recreating these is simpler than tracking orphans over the next code block await GdocXImage.delete({ gdocId: id }) @@ -2609,4 +2597,27 @@ apiRouter.get( } ) +apiRouter.post("/explorer/:slug/tags", async (req: Request, res: Response) => { + const { slug } = req.params + const { tagIds } = req.body + const explorer = await db.knexTable("explorers").where({ slug }).first() + if (!explorer) + throw new JsonError(`No explorer found for slug ${slug}`, 404) + + await db.knexInstance().transaction(async (t) => { + await t.table("explorer_tags").where({ explorerSlug: slug }).delete() + for (const tagId of tagIds) { + await t.table("explorer_tags").insert({ explorerSlug: slug, tagId }) + } + }) + + return { success: true } +}) + +apiRouter.delete("/explorer/:slug/tags", async (req: Request) => { + const { slug } = req.params + await db.knexTable("explorer_tags").where({ explorerSlug: slug }).delete() + return { success: true } +}) + export { apiRouter } diff --git a/adminSiteServer/app.tsx b/adminSiteServer/app.tsx index 500eb359cd9..21e00b4c7b8 100644 --- a/adminSiteServer/app.tsx +++ b/adminSiteServer/app.tsx @@ -31,7 +31,6 @@ import { mockSiteRouter } from "./mockSiteRouter.js" import { GIT_CMS_DIR } from "../gitCms/GitCmsConstants.js" import { GdocsContentSource } from "@ourworldindata/utils" import OwidGdocPage from "../site/gdocs/OwidGdocPage.js" -import { ExplorerAdminServer } from "../explorerAdminServer/ExplorerAdminServer.js" import { GdocFactory } from "../db/model/Gdoc/GdocFactory.js" interface OwidAdminAppOptions { @@ -124,16 +123,11 @@ export class OwidAdminApp { ) }) - const adminExplorerServer = new ExplorerAdminServer(GIT_CMS_DIR) // Public preview of a Gdoc document app.get("/gdocs/:id/preview", async (req, res) => { - const publishedExplorersBySlug = - await adminExplorerServer.getAllPublishedExplorersBySlugCached() - try { const gdoc = await GdocFactory.load( req.params.id, - publishedExplorersBySlug, GdocsContentSource.Gdocs ) diff --git a/adminSiteServer/mockSiteRouter.tsx b/adminSiteServer/mockSiteRouter.tsx index 7dd809be0d6..4c082e13b10 100644 --- a/adminSiteServer/mockSiteRouter.tsx +++ b/adminSiteServer/mockSiteRouter.tsx @@ -151,18 +151,9 @@ mockSiteRouter.get("/grapher/:slug", async (req, res) => { const entity = await Chart.getBySlug(req.params.slug) if (!entity) throw new JsonError("No such chart", 404) - const explorerAdminServer = new ExplorerAdminServer(GIT_CMS_DIR) - const publishedExplorersBySlug = - await explorerAdminServer.getAllPublishedExplorersBySlug() - // XXX add dev-prod parity for this res.set("Access-Control-Allow-Origin", "*") - res.send( - await renderPreviewDataPageOrGrapherPage( - entity.config, - publishedExplorersBySlug - ) - ) + res.send(await renderPreviewDataPageOrGrapherPage(entity.config)) }) mockSiteRouter.get("/", async (req, res) => { @@ -182,7 +173,7 @@ mockSiteRouter.get("/data-insights/:pageNumberOrSlug?", async (req, res) => { const dataInsights = await GdocDataInsight.getPublishedDataInsights(pageNumber) // calling fetchImageMetadata 20 times makes me sad, would be nice if we could cache this - await Promise.all(dataInsights.map((insight) => insight.loadState({}))) + await Promise.all(dataInsights.map((insight) => insight.loadState())) const totalPageCount = await GdocDataInsight.getTotalPageCount() return renderDataInsightsIndexPage( dataInsights, @@ -223,8 +214,6 @@ mockSiteRouter.get("/charts", async (req, res) => { mockSiteRouter.get("/datapage-preview/:id", async (req, res) => { const variableId = expectInt(req.params.id) const variableMetadata = await getVariableMetadata(variableId) - const publishedExplorersBySlug = - await explorerAdminServer.getAllPublishedExplorersBySlugCached() res.send( await renderDataPageV2({ @@ -232,7 +221,6 @@ mockSiteRouter.get("/datapage-preview/:id", async (req, res) => { variableMetadata, isPreviewing: true, useIndicatorGrapherConfigs: true, - publishedExplorersBySlug, }) ) }) diff --git a/baker/GrapherBaker.tsx b/baker/GrapherBaker.tsx index 30f85be28de..09d1c984298 100644 --- a/baker/GrapherBaker.tsx +++ b/baker/GrapherBaker.tsx @@ -56,7 +56,6 @@ import { getVariableOfDatapageIfApplicable, } from "../db/model/Variable.js" import { getDatapageDataV2, getDatapageGdoc } from "../datapage/Datapage.js" -import { ExplorerProgram } from "../explorer/ExplorerProgram.js" import { Image } from "../db/model/Image.js" import { logErrorAndMaybeSendToBugsnag } from "../serverUtils/errorLog.js" @@ -68,7 +67,6 @@ import { getSlugForTopicTag, getTagToSlugMap } from "./GrapherBakingUtils.js" const renderDatapageIfApplicable = async ( grapher: GrapherInterface, isPreviewing: boolean, - publishedExplorersBySlug?: Record, imageMetadataDictionary?: Record ) => { const variable = await getVariableOfDatapageIfApplicable(grapher) @@ -81,7 +79,6 @@ const renderDatapageIfApplicable = async ( isPreviewing: isPreviewing, useIndicatorGrapherConfigs: false, pageGrapher: grapher, - publishedExplorersBySlug, imageMetadataDictionary, }) } @@ -92,13 +89,11 @@ const renderDatapageIfApplicable = async ( */ export const renderDataPageOrGrapherPage = async ( grapher: GrapherInterface, - publishedExplorersBySlug?: Record, imageMetadataDictionary?: Record ): Promise => { const datapage = await renderDatapageIfApplicable( grapher, false, - publishedExplorersBySlug, imageMetadataDictionary ) if (datapage) return datapage @@ -123,7 +118,6 @@ export async function renderDataPageV2({ isPreviewing, useIndicatorGrapherConfigs, pageGrapher, - publishedExplorersBySlug, imageMetadataDictionary = {}, }: { variableId: number @@ -131,7 +125,6 @@ export async function renderDataPageV2({ isPreviewing: boolean useIndicatorGrapherConfigs: boolean pageGrapher?: GrapherInterface - publishedExplorersBySlug?: Record imageMetadataDictionary?: Record }) { const grapherConfigForVariable = @@ -148,7 +141,7 @@ export async function renderDataPageV2({ uniq(variableMetadata.presentation?.faqs?.map((faq) => faq.gdocId)) ) const gdocFetchPromises = faqDocs.map((gdocId) => - getDatapageGdoc(gdocId, isPreviewing, publishedExplorersBySlug) + getDatapageGdoc(gdocId, isPreviewing) ) const gdocs = await Promise.all(gdocFetchPromises) const gdocIdToFragmentIdToBlock: Record = {} @@ -314,14 +307,9 @@ export async function renderDataPageV2({ * Similar to renderDataPageOrGrapherPage(), but for admin previews */ export const renderPreviewDataPageOrGrapherPage = async ( - grapher: GrapherInterface, - publishedExplorersBySlug?: Record + grapher: GrapherInterface ) => { - const datapage = await renderDatapageIfApplicable( - grapher, - true, - publishedExplorersBySlug - ) + const datapage = await renderDatapageIfApplicable(grapher, true) if (datapage) return datapage return renderGrapherPage(grapher) @@ -385,7 +373,7 @@ const bakeGrapherPageAndVariablesPngAndSVGIfChanged = async ( const outPath = `${bakedSiteDir}/grapher/${grapher.slug}.html` await fs.writeFile( outPath, - await renderDataPageOrGrapherPage(grapher, {}, imageMetadataDictionary) + await renderDataPageOrGrapherPage(grapher, imageMetadataDictionary) ) console.log(outPath) diff --git a/baker/SiteBaker.tsx b/baker/SiteBaker.tsx index 9877f3deffb..caf2d333483 100644 --- a/baker/SiteBaker.tsx +++ b/baker/SiteBaker.tsx @@ -318,6 +318,7 @@ export class SiteBaker { thumbnail: cur.thumbnail || `${BAKED_BASE_URL}/default-thumbnail.jpg`, + tags: [], })) ) @@ -336,6 +337,7 @@ export class SiteBaker { title: chart.config.title || "", thumbnail: `${BAKED_GRAPHER_EXPORTS_BASE_URL}/${chart.config.slug}.svg`, indicatorId: datapageIndicator?.id, + tags: [], } }) ) @@ -506,9 +508,7 @@ export class SiteBaker { // this is a no-op if the gdoc doesn't have an all-chart block await publishedGdoc.loadRelatedCharts() - const publishedExplorersBySlug = - await this.explorerAdminServer.getAllPublishedExplorersBySlugCached() - await publishedGdoc.validate(publishedExplorersBySlug) + await publishedGdoc.validate() if ( publishedGdoc.errors.filter( (e) => e.type === OwidGdocErrorMessageType.Error @@ -705,9 +705,7 @@ export class SiteBaker { } dataInsight.latestDataInsights = latestDataInsights - const publishedExplorersBySlug = - await this.explorerAdminServer.getAllPublishedExplorersBySlugCached() - await dataInsight.validate(publishedExplorersBySlug) + await dataInsight.validate() if ( dataInsight.errors.filter( (e) => e.type === OwidGdocErrorMessageType.Error diff --git a/baker/algolia/indexExplorersToAlgolia.ts b/baker/algolia/indexExplorersToAlgolia.ts index 812d9486086..cab27ac9de9 100644 --- a/baker/algolia/indexExplorersToAlgolia.ts +++ b/baker/algolia/indexExplorersToAlgolia.ts @@ -158,9 +158,13 @@ const getExplorerRecords = async (): Promise => { ? textChunks : [""] + const formattedTitle = `${getNullishJSONValueAsPlaintext( + title + )} Data Explorer` + return textChunksForIteration.map((chunk, i) => ({ slug, - title: getNullishJSONValueAsPlaintext(title), + title: formattedTitle, subtitle: getNullishJSONValueAsPlaintext(subtitle), views_7d: pageviews[`/explorers/${slug}`]?.views_7d ?? 0, text: chunk, diff --git a/baker/siteRenderers.tsx b/baker/siteRenderers.tsx index e805c32d833..a43dabcb1da 100644 --- a/baker/siteRenderers.tsx +++ b/baker/siteRenderers.tsx @@ -169,16 +169,12 @@ export const renderGdocsPageBySlug = async ( slug: string, isPreviewing: boolean = false ): Promise => { - const explorerAdminServer = new ExplorerAdminServer(GIT_CMS_DIR) - const publishedExplorersBySlug = - await explorerAdminServer.getAllPublishedExplorersBySlug() - - const gdoc = await GdocFactory.loadBySlug(slug, publishedExplorersBySlug) + const gdoc = await GdocFactory.loadBySlug(slug) if (!gdoc) { throw new Error(`Failed to render an unknown GDocs post: ${slug}.`) } - await gdoc.loadState(publishedExplorersBySlug) + await gdoc.loadState() return renderGdoc(gdoc, isPreviewing) } @@ -267,7 +263,7 @@ export const renderFrontPage = async () => { id: GDOCS_HOMEPAGE_CONFIG_DOCUMENT_ID, }) if (!frontPageConfigGdoc) throw new Error("No front page config found") - await frontPageConfigGdoc.loadState({}) + await frontPageConfigGdoc.loadState() const frontPageConfig: any = frontPageConfigGdoc.content const featuredPosts: { slug: string; position: number }[] = frontPageConfig["featured-posts"] ?? [] @@ -331,8 +327,7 @@ export const renderFrontPage = async () => { export const renderDonatePage = async () => { const faqsGdoc = (await GdocFactory.load( - GDOCS_DONATE_FAQS_DOCUMENT_ID, - {} + GDOCS_DONATE_FAQS_DOCUMENT_ID )) as GdocPost if (!faqsGdoc) throw new Error( @@ -751,7 +746,9 @@ const getExplorerTitleByUrl = async (url: Url): Promise => { : undefined) ) } - return explorer.explorerTitle + // Maintaining old behaviour so that we don't have to redesign WP prominent links + // since we're removing WP soon + return `${explorer.explorerTitle} Data Explorer` } /** diff --git a/datapage/Datapage.ts b/datapage/Datapage.ts index ee015d18908..5470e246fcc 100644 --- a/datapage/Datapage.ts +++ b/datapage/Datapage.ts @@ -11,7 +11,6 @@ import { getNextUpdateFromVariable, omitUndefinedValues, } from "@ourworldindata/utils" -import { ExplorerProgram } from "../explorer/ExplorerProgram.js" import { GdocPost } from "../db/model/Gdoc/GdocPost.js" import { GdocFactory } from "../db/model/Gdoc/GdocFactory.js" import { OwidGoogleAuth } from "../db/OwidGoogleAuth.js" @@ -78,8 +77,7 @@ export const getDatapageDataV2 = async ( */ export const getDatapageGdoc = async ( googleDocEditLinkOrId: string, - isPreviewing: boolean, - publishedExplorersBySlug?: Record + isPreviewing: boolean ): Promise => { // Get the google doc id from the datapage JSON file and return early if // none found @@ -100,12 +98,9 @@ export const getDatapageGdoc = async ( // support images (imageMetadata won't be set). const datapageGdoc = - isPreviewing && - publishedExplorersBySlug && - OwidGoogleAuth.areGdocAuthKeysSet() + isPreviewing && OwidGoogleAuth.areGdocAuthKeysSet() ? ((await GdocFactory.load( googleDocId, - publishedExplorersBySlug, GdocsContentSource.Gdocs )) as GdocPost) : await GdocPost.findOneBy({ id: googleDocId }) diff --git a/db/db.ts b/db/db.ts index 34090537fb8..b94d17ba978 100644 --- a/db/db.ts +++ b/db/db.ts @@ -10,6 +10,8 @@ import { GRAPHER_DB_PORT, } from "../settings/serverSettings.js" import { registerExitHandler } from "./cleanup.js" +import { keyBy } from "@ourworldindata/utils" +import { DbChartTagJoin } from "@ourworldindata/types" let typeormDataSource: DataSource export const getConnection = async ( @@ -140,3 +142,65 @@ export const getSlugsWithPublishedGdocsSuccessors = async ( knex ).then((rows) => new Set(rows.map((row: any) => row.slug))) } + +export const getExplorerTags = async ( + knex: Knex +): Promise<{ slug: string; tags: DbChartTagJoin[] }[]> => { + return knexRaw<{ slug: string; tags: string }>( + `-- sql + SELECT + ext.explorerSlug as slug, + CASE + WHEN COUNT(t.id) = 0 THEN JSON_ARRAY() + ELSE JSON_ARRAYAGG(JSON_OBJECT('name', t.name, 'id', t.id)) + END AS tags + FROM + explorer_tags ext + LEFT JOIN tags t ON + ext.tagId = t.id + GROUP BY + ext.explorerSlug`, + knex + ).then((rows) => + rows.map((row) => ({ + slug: row.slug, + tags: JSON.parse(row.tags) as DbChartTagJoin[], + })) + ) +} + +export const getPublishedExplorersBySlug = async ( + knex: Knex +): Promise<{ + [slug: string]: { + slug: string + title: string + subtitle: string + tags: DbChartTagJoin[] + } +}> => { + const tags = await getExplorerTags(knex) + const tagsBySlug = keyBy(tags, "slug") + return knexRaw( + `-- sql + SELECT + slug, + config->>"$.explorerTitle" as title, + config->>"$.explorerSubtitle" as subtitle + FROM + explorers + WHERE + isPublished = TRUE`, + knex + ).then((rows) => { + const processed = rows.map((row: any) => { + return { + slug: row.slug, + title: row.title, + subtitle: row.subtitle === "null" ? "" : row.subtitle, + tags: tagsBySlug[row.slug]?.tags ?? [], + } + }) + return keyBy(processed, "slug") + }) +} diff --git a/db/migration/1707502831161-ExplorerTags.ts b/db/migration/1707502831161-ExplorerTags.ts new file mode 100644 index 00000000000..903ce8aa54c --- /dev/null +++ b/db/migration/1707502831161-ExplorerTags.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class ExplorerTags1707502831161 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE explorer_tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + explorerSlug VARCHAR(150) NOT NULL, + tagId INT NOT NULL, + UNIQUE KEY (explorerSlug, tagId), + FOREIGN KEY (tagId) REFERENCES tags(id) + ); + + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE IF EXISTS explorer_tags; + `) + } +} diff --git a/db/model/Gdoc/GdocBase.ts b/db/model/Gdoc/GdocBase.ts index 2eb05ff8645..ab0ba812fd4 100644 --- a/db/model/Gdoc/GdocBase.ts +++ b/db/model/Gdoc/GdocBase.ts @@ -9,6 +9,7 @@ import { ManyToMany, JoinTable, } from "typeorm" +import * as db from "../../db" import { getUrlTarget } from "@ourworldindata/components" import { LinkedChart, @@ -471,6 +472,19 @@ export class GdocBase extends BaseEntity implements OwidGdocBaseInterface { return links }) + .with( + { + type: "explorer-tiles", + }, + (explorerTiles) => + explorerTiles.explorers.map(({ url }) => + Link.createFromUrl({ + url, + source: this, + componentType: "explorer-tiles", + }) + ) + ) .with( { type: "research-and-writing", @@ -571,9 +585,7 @@ export class GdocBase extends BaseEntity implements OwidGdocBaseInterface { } } - async loadLinkedCharts( - publishedExplorersBySlug: Record - ): Promise { + async loadLinkedCharts(): Promise { const slugToIdMap = await Chart.mapSlugsToIds() const linkedGrapherCharts = await Promise.all( [...this.linkedChartSlugs.grapher.values()].map( @@ -593,6 +605,7 @@ export class GdocBase extends BaseEntity implements OwidGdocBaseInterface { tab, resolvedUrl: `${BAKED_GRAPHER_URL}/${resolvedSlug}`, thumbnail: `${BAKED_GRAPHER_EXPORTS_BASE_URL}/${resolvedSlug}.svg`, + tags: [], indicatorId: datapageIndicator?.id, } return linkedChart @@ -600,16 +613,22 @@ export class GdocBase extends BaseEntity implements OwidGdocBaseInterface { ) ).then(excludeNullish) + const publishedExplorersBySlug = await db.getPublishedExplorersBySlug( + db.knexInstance() + ) + const linkedExplorerCharts = await Promise.all( - [...this.linkedChartSlugs.explorer.values()].map((originalSlug) => { + this.linkedChartSlugs.explorer.map((originalSlug) => { const explorer = publishedExplorersBySlug[originalSlug] if (!explorer) return const linkedChart: LinkedChart = { // we are assuming explorer slugs won't change originalSlug, - title: explorer?.explorerTitle ?? "", + title: explorer?.title ?? "", + subtitle: explorer?.subtitle ?? "", resolvedUrl: `${BAKED_BASE_URL}/${EXPLORERS_ROUTE_FOLDER}/${originalSlug}`, thumbnail: `${BAKED_BASE_URL}/default-thumbnail.jpg`, + tags: explorer.tags, } return linkedChart }) @@ -687,9 +706,7 @@ export class GdocBase extends BaseEntity implements OwidGdocBaseInterface { this.content = archieToEnriched(text, this._enrichSubclassContent) } - async validate( - publishedExplorersBySlug: Record - ): Promise { + async validate(): Promise { const filenameErrors: OwidGdocErrorMessage[] = this.filenames.reduce( ( errors: OwidGdocErrorMessage[], @@ -714,6 +731,9 @@ export class GdocBase extends BaseEntity implements OwidGdocBaseInterface { ) const chartIdsBySlug = await Chart.mapSlugsToIds() + const publishedExplorersBySlug = await db.getPublishedExplorersBySlug( + db.knexInstance() + ) const linkErrors: OwidGdocErrorMessage[] = this.links.reduce( (errors: OwidGdocErrorMessage[], link): OwidGdocErrorMessage[] => { @@ -742,6 +762,7 @@ export class GdocBase extends BaseEntity implements OwidGdocBaseInterface { }) } } + if (link.linkType === "explorer") { if (!publishedExplorersBySlug[link.target]) { errors.push({ @@ -785,15 +806,14 @@ export class GdocBase extends BaseEntity implements OwidGdocBaseInterface { ] } - async loadState( - publishedExplorersBySlug: Record - ): Promise { + async loadState(): Promise { await this.loadLinkedDocuments() await this.loadImageMetadata() - await this.loadLinkedCharts(publishedExplorersBySlug) + await this.loadLinkedCharts() + await this.loadLinkedCharts() await this.loadLinkedIndicators() // depends on linked charts await this._loadSubclassAttachments() - await this.validate(publishedExplorersBySlug) + await this.validate() } toJSON(): T { diff --git a/db/model/Gdoc/GdocFactory.ts b/db/model/Gdoc/GdocFactory.ts index 10f96f1114b..78cf0d1a802 100644 --- a/db/model/Gdoc/GdocFactory.ts +++ b/db/model/Gdoc/GdocFactory.ts @@ -54,10 +54,7 @@ export class GdocFactory { .exhaustive() } - static async create( - id: string, - publishedExplorersBySlug: Record - ): Promise { + static async create(id: string): Promise { // Fetch the data from Google Docs and save it to the database // We have to fetch it here because we need to know the type of the Gdoc in this.load() const base = new GdocBase(id) @@ -66,21 +63,14 @@ export class GdocFactory { // Load its metadata and state so that subclass parsing & validation is also done. // This involves a second call to the DB and Google, which makes me sad, but it'll do for now. - const gdoc = await this.load( - id, - publishedExplorersBySlug, - GdocsContentSource.Gdocs - ) + const gdoc = await this.load(id, GdocsContentSource.Gdocs) await gdoc.save() return gdoc } - static async loadBySlug( - slug: string, - publishedExplorersBySlug: Record - ): Promise { + static async loadBySlug(slug: string): Promise { const base = await GdocBase.findOne({ where: { slug, published: true }, }) @@ -89,14 +79,13 @@ export class GdocFactory { `No published Google Doc with slug "${slug}" found in the database` ) } - return this.load(base.id, publishedExplorersBySlug) + return this.load(base.id) } // From an ID, get a Gdoc object with all its metadata and state loaded, in its correct subclass. // If contentSource is Gdocs, use live data from Google, otherwise use the data in the DB. static async load( id: string, - publishedExplorersBySlug: Record, contentSource?: GdocsContentSource ): Promise { const base = await GdocBase.findOne({ @@ -138,7 +127,7 @@ export class GdocFactory { await gdoc.fetchAndEnrichGdoc() } - await gdoc.loadState(publishedExplorersBySlug) + await gdoc.loadState() return gdoc } diff --git a/db/model/Gdoc/enrichedToMarkdown.ts b/db/model/Gdoc/enrichedToMarkdown.ts index 11cfefb2b4c..7fba4bcf634 100644 --- a/db/model/Gdoc/enrichedToMarkdown.ts +++ b/db/model/Gdoc/enrichedToMarkdown.ts @@ -264,6 +264,7 @@ ${links}` }) return "\n" + rows.join("\n") // markdown tables need a leading empty line }) + .with({ type: "explorer-tiles" }, () => undefined) // Note: dropped .with({ type: "blockquote" }, (b): string | undefined => { const text = excludeNullish( b.text.map((text) => diff --git a/db/model/Gdoc/enrichedToRaw.ts b/db/model/Gdoc/enrichedToRaw.ts index 5c8e4d27c16..4fc3648289e 100644 --- a/db/model/Gdoc/enrichedToRaw.ts +++ b/db/model/Gdoc/enrichedToRaw.ts @@ -36,6 +36,7 @@ import { RawBlockVideo, RawBlockTable, RawBlockBlockquote, + RawBlockExplorerTiles, RawBlockKeyIndicator, RawBlockKeyIndicatorCollection, } from "@ourworldindata/types" @@ -423,6 +424,16 @@ export function enrichedBlockToRawBlock( }, } }) + .with({ type: "explorer-tiles" }, (b): RawBlockExplorerTiles => { + return { + type: "explorer-tiles", + value: { + title: b.title, + subtitle: b.subtitle, + explorers: b.explorers, + }, + } + }) .with({ type: "blockquote" }, (b): RawBlockBlockquote => { return { type: "blockquote", diff --git a/db/model/Gdoc/exampleEnrichedBlocks.ts b/db/model/Gdoc/exampleEnrichedBlocks.ts index 2fba952db54..b3e56aad2ce 100644 --- a/db/model/Gdoc/exampleEnrichedBlocks.ts +++ b/db/model/Gdoc/exampleEnrichedBlocks.ts @@ -529,6 +529,17 @@ export const enrichedBlockExamples: Record< ], parseErrors: [], }, + ["explorer-tiles"]: { + type: "explorer-tiles", + title: "Explore the data", + subtitle: + "Our explorers show even more data than our normal visualizations.", + explorers: [ + { url: "https://ourworldindata.org/explorers/energy" }, + { url: "https://ourworldindata.org/explorers/poverty-explorer" }, + ], + parseErrors: [], + }, blockquote: { type: "blockquote", text: [enrichedBlockText], diff --git a/db/model/Gdoc/rawToArchie.ts b/db/model/Gdoc/rawToArchie.ts index 8b9c70a7bec..64ac50c0c23 100644 --- a/db/model/Gdoc/rawToArchie.ts +++ b/db/model/Gdoc/rawToArchie.ts @@ -37,6 +37,7 @@ import { RawBlockBlockquote, RawBlockKeyIndicator, RawBlockKeyIndicatorCollection, + RawBlockExplorerTiles, } from "@ourworldindata/types" import { isArray } from "@ourworldindata/utils" import { match } from "ts-pattern" @@ -590,6 +591,22 @@ function* rawBlockRowToArchieMLString( yield "{}" } +function* rawBlockExplorerTilesToArchieMLString( + block: RawBlockExplorerTiles +): Generator { + yield "{.explorer-tiles}" + yield* propertyToArchieMLString("title", block.value) + yield* propertyToArchieMLString("subtitle", block.value) + if (block.value.explorers) { + yield "[.explorers]" + for (const explorer of block.value.explorers) { + yield* propertyToArchieMLString("url", explorer) + } + yield "[]" + } + yield "{}" +} + function* rawBlockBlockquoteToArchieMLString( blockquote: RawBlockBlockquote ): Generator { @@ -715,6 +732,7 @@ export function* OwidRawGdocBlockToArchieMLStringGenerator( .with({ type: "entry-summary" }, rawBlockEntrySummaryToArchieMLString) .with({ type: "table" }, rawBlockTableToArchieMLString) .with({ type: "table-row" }, rawBlockRowToArchieMLString) + .with({ type: "explorer-tiles" }, rawBlockExplorerTilesToArchieMLString) .with({ type: "blockquote" }, rawBlockBlockquoteToArchieMLString) .with({ type: "key-indicator" }, rawBlockKeyIndicatorToArchieMLString) .with( diff --git a/db/model/Gdoc/rawToEnriched.ts b/db/model/Gdoc/rawToEnriched.ts index 92e538d0c10..e07f5250072 100644 --- a/db/model/Gdoc/rawToEnriched.ts +++ b/db/model/Gdoc/rawToEnriched.ts @@ -12,6 +12,7 @@ import { EnrichedBlockHorizontalRule, EnrichedBlockHtml, EnrichedBlockImage, + EnrichedBlockExplorerTiles, EnrichedBlockVideo, EnrichedBlockKeyInsights, EnrichedBlockList, @@ -103,6 +104,7 @@ import { EnrichedBlockBlockquote, RawBlockKeyIndicatorCollection, EnrichedBlockKeyIndicatorCollection, + RawBlockExplorerTiles, } from "@ourworldindata/types" import { traverseEnrichedSpan, @@ -117,7 +119,7 @@ import { partition, compact, } from "@ourworldindata/utils" -import { checkIsInternalLink } from "@ourworldindata/components" +import { checkIsInternalLink, getLinkType } from "@ourworldindata/components" import { extractUrl, getTitleSupertitleFromHeadingText, @@ -199,6 +201,7 @@ export function parseRawBlocksToEnrichedBlocks( .with({ type: "expandable-paragraph" }, parseExpandableParagraph) .with({ type: "align" }, parseAlign) .with({ type: "entry-summary" }, parseEntrySummary) + .with({ type: "explorer-tiles" }, parseExplorerTiles) .with({ type: "table" }, parseTable) .with({ type: "key-indicator" }, parseKeyIndicator) .with({ type: "key-indicator-collection" }, parseKeyIndicatorCollection) @@ -1803,6 +1806,48 @@ function parseEntrySummary( } } +function parseExplorerTiles( + raw: RawBlockExplorerTiles +): EnrichedBlockExplorerTiles { + function createError(error: ParseError): EnrichedBlockExplorerTiles { + return { + type: "explorer-tiles", + title: "", + subtitle: "", + explorers: [], + parseErrors: [error], + } + } + + if (!raw.value.title) + return createError({ message: "Explorer tiles missing title" }) + + if (!raw.value.subtitle) + return createError({ message: "Explorer tiles missing subtitle" }) + + if (!raw.value.explorers?.length) + return createError({ message: "Explorer tiles missing explorers" }) + + const parsedExplorerUrls: { url: string }[] = [] + for (const explorer of raw.value.explorers) { + const url = extractUrl(explorer.url) + if (getLinkType(url) !== "explorer") { + return createError({ + message: `Explorer tiles contains a non-explorer URL: ${url}`, + }) + } + parsedExplorerUrls.push({ url }) + } + + return { + type: "explorer-tiles", + title: raw.value.title, + subtitle: raw.value.subtitle, + explorers: parsedExplorerUrls, + parseErrors: [], + } +} + export function parseRefs({ refs, refsByFirstAppearance, diff --git a/explorer/Explorer.sample.tsx b/explorer/Explorer.sample.tsx index 05cd40c7164..1cbb00d7f06 100644 --- a/explorer/Explorer.sample.tsx +++ b/explorer/Explorer.sample.tsx @@ -4,7 +4,7 @@ import { GrapherTabOption } from "@ourworldindata/types" import { GrapherProgrammaticInterface } from "@ourworldindata/grapher" import { Explorer, ExplorerProps } from "./Explorer.js" -const SampleExplorerOfGraphersProgram = `explorerTitle CO₂ Data Explorer +const SampleExplorerOfGraphersProgram = `explorerTitle CO₂ isPublished false explorerSubtitle Download the complete Our World in Data CO₂ and GHG Emissions Dataset. subNavId co2 diff --git a/explorer/Explorer.scss b/explorer/Explorer.scss index dfa1486a656..731ab139297 100644 --- a/explorer/Explorer.scss +++ b/explorer/Explorer.scss @@ -39,10 +39,10 @@ html.IsInIframe #ExplorerContainer { .ExplorerSubtitle { color: #7a899e; font-size: 13px; - - a { - @include owid-link-60; - } + } + .ExplorerDownloadLink { + font-size: 13px; + @include owid-link-60; } } diff --git a/explorer/Explorer.tsx b/explorer/Explorer.tsx index 007e2816cdf..69f28e1c52a 100644 --- a/explorer/Explorer.tsx +++ b/explorer/Explorer.tsx @@ -786,9 +786,8 @@ export class Explorer private renderHeaderElement() { return (
    -
    - {this.explorerProgram.explorerTitle} + {this.explorerProgram.explorerTitle} Data Explorer
    + {this.explorerProgram.downloadDataLink && ( + + Download this dataset + + )}
    ) } diff --git a/explorer/ExplorerConstants.ts b/explorer/ExplorerConstants.ts index dfb6510c465..ec54c6e51d9 100644 --- a/explorer/ExplorerConstants.ts +++ b/explorer/ExplorerConstants.ts @@ -72,6 +72,8 @@ export const ExplorerContainerId = "ExplorerContainer" export const GetAllExplorersRoute = "allExplorers.json" +export const GetAllExplorersTagsRoute = "allExplorersTags.json" + export const EXPLORERS_ROUTE_FOLDER = "explorers" // Url path: http://owid.org/{explorers} export const EXPLORERS_GIT_CMS_FOLDER = "explorers" // Disk path: /home/owid/git-content/{explorers} export const EXPLORERS_PREVIEW_ROUTE = `${EXPLORERS_ROUTE_FOLDER}/preview` diff --git a/explorer/ExplorerGrammar.ts b/explorer/ExplorerGrammar.ts index 30339ef40a0..ddbb3934d06 100644 --- a/explorer/ExplorerGrammar.ts +++ b/explorer/ExplorerGrammar.ts @@ -49,7 +49,7 @@ export const ExplorerGrammar: Grammar = { explorerTitle: { ...StringCellDef, keyword: "explorerTitle", - valuePlaceholder: "Life Expectancy Data Explorer", + valuePlaceholder: "Life Expectancy", description: "The title will appear in the top left corner of the Explorer.", }, diff --git a/packages/@ourworldindata/components/src/styles/colors.scss b/packages/@ourworldindata/components/src/styles/colors.scss index ca523f2f065..7fe5ff77b2a 100644 --- a/packages/@ourworldindata/components/src/styles/colors.scss +++ b/packages/@ourworldindata/components/src/styles/colors.scss @@ -11,6 +11,7 @@ $amber: #f7c020; $blue-100: #002147; $blue-90: #1d3d63; $blue-60: #577291; +$blue-55: #46688f; $blue-50: #6e87a2; $blue-40: #98a9bd; $blue-30: #a4b6ca; diff --git a/packages/@ourworldindata/types/src/gdocTypes/ArchieMlComponents.ts b/packages/@ourworldindata/types/src/gdocTypes/ArchieMlComponents.ts index a569442cabc..3149882fedd 100644 --- a/packages/@ourworldindata/types/src/gdocTypes/ArchieMlComponents.ts +++ b/packages/@ourworldindata/types/src/gdocTypes/ArchieMlComponents.ts @@ -705,6 +705,22 @@ export type EnrichedBlockBlockquote = { citation?: string } & EnrichedBlockWithParseErrors +export type RawBlockExplorerTiles = { + type: "explorer-tiles" + value: { + title?: string + subtitle?: string + explorers?: { url: string }[] + } +} + +export type EnrichedBlockExplorerTiles = { + type: "explorer-tiles" + title: string + subtitle: string + explorers: { url: string }[] +} & EnrichedBlockWithParseErrors + export type Ref = { id: string // Can be -1 @@ -724,6 +740,7 @@ export type OwidRawGdocBlock = | RawBlockChart | RawBlockScroller | RawBlockChartStory + | RawBlockExplorerTiles | RawBlockImage | RawBlockVideo | RawBlockList @@ -764,6 +781,7 @@ export type OwidEnrichedGdocBlock = | EnrichedBlockChart | EnrichedBlockScroller | EnrichedBlockChartStory + | EnrichedBlockExplorerTiles | EnrichedBlockImage | EnrichedBlockVideo | EnrichedBlockList diff --git a/packages/@ourworldindata/types/src/gdocTypes/Gdoc.ts b/packages/@ourworldindata/types/src/gdocTypes/Gdoc.ts index 5ec0af59399..a4adba0c640 100644 --- a/packages/@ourworldindata/types/src/gdocTypes/Gdoc.ts +++ b/packages/@ourworldindata/types/src/gdocTypes/Gdoc.ts @@ -11,6 +11,7 @@ import { RawBlockText, RefDictionary, } from "./ArchieMlComponents.js" +import { DbChartTagJoin } from "../dbTypes/ChartTags.js" export enum OwidGdocPublicationContext { unlisted = "unlisted", @@ -22,8 +23,10 @@ export interface LinkedChart { originalSlug: string resolvedUrl: string title: string - tab?: GrapherTabOption + subtitle?: string thumbnail?: string + tags: DbChartTagJoin[] + tab?: GrapherTabOption indicatorId?: number // in case of a datapage } diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index 2e11236e54d..9dd060b9978 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -166,6 +166,7 @@ export { type RawBlockChartStory, type RawBlockChartValue, type RawBlockExpandableParagraph, + type RawBlockExplorerTiles, type RawBlockGraySection, type RawBlockHeading, type RawBlockHorizontalRule, @@ -213,6 +214,7 @@ export { type EnrichedBlockChart, type EnrichedBlockChartStory, type EnrichedBlockExpandableParagraph, + type EnrichedBlockExplorerTiles, type EnrichedBlockGraySection, type EnrichedBlockHeading, type EnrichedBlockHorizontalRule, diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index 25725627328..28469307883 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -1663,7 +1663,8 @@ export function traverseEnrichedBlocks( "sdg-toc", "topic-page-intro", "all-charts", - "entry-summary" + "entry-summary", + "explorer-tiles" ), }, callback diff --git a/public/explorer-thumbnail.webp b/public/explorer-thumbnail.webp new file mode 100644 index 00000000000..0974a675b3d Binary files /dev/null and b/public/explorer-thumbnail.webp differ diff --git a/site/ChartsIndexPage.tsx b/site/ChartsIndexPage.tsx index 3d90929f227..b0499e58625 100644 --- a/site/ChartsIndexPage.tsx +++ b/site/ChartsIndexPage.tsx @@ -115,6 +115,7 @@ export const ChartsIndexPage = (props: { ({ title, explorerTitle, + explorerSubtitle, slug, }) => (
  • @@ -124,6 +125,9 @@ export const ChartsIndexPage = (props: { {explorerTitle ?? title} +

    + {explorerSubtitle} +

  • ) )} diff --git a/site/ExplorerPage.tsx b/site/ExplorerPage.tsx index aba5a1d4761..44a26838aa9 100644 --- a/site/ExplorerPage.tsx +++ b/site/ExplorerPage.tsx @@ -67,6 +67,7 @@ export const ExplorerPage = (props: ExplorerPageSettings) => { subNavId, subNavCurrentId, explorerTitle, + explorerSubtitle, slug, thumbnail, hideAlertBanner, @@ -100,7 +101,8 @@ window.Explorer.renderSingleExplorerOnExplorerPage(explorerProgram, grapherConfi diff --git a/site/gdocs/components/ArticleBlock.tsx b/site/gdocs/components/ArticleBlock.tsx index e82eb27bb27..bdf728cff33 100644 --- a/site/gdocs/components/ArticleBlock.tsx +++ b/site/gdocs/components/ArticleBlock.tsx @@ -34,6 +34,7 @@ import { ResearchAndWriting } from "./ResearchAndWriting.js" import { AllCharts } from "./AllCharts.js" import Video from "./Video.js" import { Table } from "./Table.js" +import { ExplorerTiles } from "./ExplorerTiles.js" import KeyIndicator from "./KeyIndicator.js" import KeyIndicatorCollection from "./KeyIndicatorCollection.js" @@ -64,6 +65,7 @@ const layouts: { [key in Container]: Layouts} = { ["default"]: "col-start-5 span-cols-6 col-md-start-3 span-md-cols-10 span-sm-cols-12 col-sm-start-2", ["divider"]: "col-start-2 span-cols-12", ["explorer"]: "col-start-2 span-cols-12", + ["explorer-tiles"]: "grid grid-cols-12 span-cols-12 col-start-2", ["gray-section"]: "span-cols-14 grid grid-cols-12-full-width", ["heading"]: "col-start-5 span-cols-6 col-md-start-3 span-md-cols-10 span-sm-cols-12 col-sm-start-2", ["horizontal-rule"]: "col-start-5 span-cols-6 col-md-start-3 span-md-cols-10 span-sm-cols-12 col-sm-start-2", @@ -627,6 +629,12 @@ export default function ArticleBlock({ ) }) + .with({ type: "explorer-tiles" }, (block) => ( + + )) .with({ type: "key-indicator" }, (block) => ( )) diff --git a/site/gdocs/components/ExplorerTiles.scss b/site/gdocs/components/ExplorerTiles.scss new file mode 100644 index 00000000000..e592dca19d5 --- /dev/null +++ b/site/gdocs/components/ExplorerTiles.scss @@ -0,0 +1,68 @@ +.article-block__explorer-tiles { + margin-bottom: 40px; + .explorer-tiles__title { + margin: 0; + } + .explorer-tiles__subtitle { + color: $blue-60; + margin: 0 0 16px 0; + } + .explorer-tiles__cta { + display: block; + padding: 8.5px 16px; + color: $vermillion; + border: 1px solid $vermillion; + border-radius: 0; + width: fit-content; + justify-self: end; + grid-row: span 2; + align-self: center; + height: 40px; + &:hover { + color: $accent-vermillion; + border-color: $accent-vermillion; + } + @include sm-only { + justify-self: start; + margin-top: 16px; + order: 4; + text-align: center; + width: 100%; + } + } + .explorer-tiles-grid { + row-gap: var(--grid-gap); + @include md-down { + grid-template-rows: repeat(2, 1fr); + column-gap: var(--grid-gap); + } + } + .explorer-tile { + background-image: url("/explorer-thumbnail.webp"); + background-size: cover; + padding: 16px; + display: flex; + flex-wrap: wrap; + align-content: space-between; + } + .explorer-tile__icon { + background-color: $blue-55; + border-radius: 50%; + width: 40px; + height: 40px; + } + .explorer-tile__text-container { + width: 100%; + margin-top: 18px; + } + .explorer-tile__title, + .explorer-tile__suffix { + margin: 0; + } + .explorer-tile__title { + color: #fff; + } + .explorer-tile__suffix { + color: $blue-40; + } +} diff --git a/site/gdocs/components/ExplorerTiles.tsx b/site/gdocs/components/ExplorerTiles.tsx new file mode 100644 index 00000000000..02e5f837430 --- /dev/null +++ b/site/gdocs/components/ExplorerTiles.tsx @@ -0,0 +1,74 @@ +import { EnrichedBlockExplorerTiles } from "@ourworldindata/types" +import React, { useContext } from "react" +import { useLinkedChart } from "../utils.js" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faArrowRight } from "@fortawesome/free-solid-svg-icons" +import { DocumentContext } from "../OwidGdoc.js" + +function ExplorerTile({ url }: { url: string }) { + const { linkedChart, errorMessage } = useLinkedChart(url) + const { isPreviewing } = useContext(DocumentContext) + if (errorMessage && isPreviewing) { + return

    {errorMessage}

    + } + if (!linkedChart) { + return null + } + const icon = linkedChart.tags[0] ? ( + + ) : null + + return ( + + {icon} +
    +

    + {linkedChart.title} +

    +

    Data Explorer

    +
    +
    + ) +} + +type ExplorerTilesProps = EnrichedBlockExplorerTiles & { + className?: string +} + +export function ExplorerTiles({ + className, + title, + subtitle, + explorers, +}: ExplorerTilesProps) { + return ( +
    +

    + {title} +

    + + See all our charts and explorers{" "} + + +

    + {subtitle} +

    +
    + {explorers.map((explorer) => ( + + ))} +
    +
    + ) +} diff --git a/site/gdocs/components/ProminentLink.tsx b/site/gdocs/components/ProminentLink.tsx index dd088085f15..f2ead43cd08 100644 --- a/site/gdocs/components/ProminentLink.tsx +++ b/site/gdocs/components/ProminentLink.tsx @@ -50,17 +50,17 @@ export const ProminentLink = (props: { title = title ?? linkedDocument?.title description = description ?? linkedDocument?.excerpt thumbnail = thumbnail ?? linkedDocument?.["featured-image"] - } else if (linkType === "grapher" || linkType === "explorer") { + } else if (linkType === "grapher") { href = `${linkedChart?.resolvedUrl}` title = title ?? linkedChart?.title thumbnail = thumbnail ?? linkedChart?.thumbnail description = - // Adding extra context for graphers by default - // Not needed for Explorers as their titles are self-explanatory - description ?? - (linkType === "grapher" - ? "See the data in our interactive visualization" - : "") + description ?? "See the data in our interactive visualization" + } else if (linkType === "explorer") { + href = `${linkedChart?.resolvedUrl}` + title = title ?? `${linkedChart?.title} Data Explorer` + thumbnail = thumbnail ?? linkedChart?.thumbnail + description = description ?? linkedChart?.subtitle } const Thumbnail = ({ thumbnail }: { thumbnail: string }) => { diff --git a/site/owid.scss b/site/owid.scss index f281585e1e7..bc8fd030e16 100644 --- a/site/owid.scss +++ b/site/owid.scss @@ -94,6 +94,7 @@ @import "./gdocs/components/AdditionalCharts.scss"; @import "./gdocs/components/ResearchAndWriting.scss"; @import "./gdocs/components/Chart.scss"; +@import "./gdocs/components/ExplorerTiles.scss"; @import "./gdocs/components/KeyIndicator.scss"; @import "./gdocs/components/KeyIndicatorCollection.scss"; @import "./DataPage.scss"; @@ -1007,6 +1008,11 @@ html:not(.js) { .ChartsIndexPage #explorers-section { padding-bottom: $vertical-spacing; border-bottom: 1px solid $blue-20; + .charts-index-page__explorer-subtitle { + @include body-3-medium-italic; + margin-top: 0; + margin-bottom: 4px; + } } .NotFoundPage > main {