diff --git a/adminSiteServer/adminRouter.tsx b/adminSiteServer/adminRouter.tsx index 04c3c65f300..bb818314245 100644 --- a/adminSiteServer/adminRouter.tsx +++ b/adminSiteServer/adminRouter.tsx @@ -304,7 +304,8 @@ getPlainRouteWithROTransaction( if (slug === DefaultNewExplorerSlug) return renderExplorerPage( new ExplorerProgram(DefaultNewExplorerSlug, ""), - knex + knex, + { isPreviewing: true } ) if ( !slug || @@ -312,7 +313,9 @@ getPlainRouteWithROTransaction( ) return `File not found` const explorer = await explorerAdminServer.getExplorerFromFile(filename) - const explorerPage = await renderExplorerPage(explorer, knex) + const explorerPage = await renderExplorerPage(explorer, knex, { + isPreviewing: true, + }) return res.send(explorerPage) } diff --git a/adminSiteServer/mockSiteRouter.tsx b/adminSiteServer/mockSiteRouter.tsx index 0c15887261e..869b4ad3b61 100644 --- a/adminSiteServer/mockSiteRouter.tsx +++ b/adminSiteServer/mockSiteRouter.tsx @@ -189,8 +189,10 @@ getPlainRouteWithROTransaction( const program = await explorerAdminServer.getExplorerFromSlug(explorerSlug) const explorerPage = await renderExplorerPage(program, trx, { - explorerUrlMigrationId: migrationId, - baseQueryStr, + urlMigrationSpec: { + explorerUrlMigrationId: migrationId, + baseQueryStr, + }, }) res.send(explorerPage) } diff --git a/baker/ExplorerBaker.tsx b/baker/ExplorerBaker.tsx index 6b2e1a79315..0af72408180 100644 --- a/baker/ExplorerBaker.tsx +++ b/baker/ExplorerBaker.tsx @@ -6,6 +6,119 @@ import { ExplorerAdminServer } from "../explorerAdminServer/ExplorerAdminServer. import { explorerRedirectTable } from "../explorerAdminServer/ExplorerRedirects.js" import { renderExplorerPage } from "./siteRenderers.js" import * as db from "../db/db.js" +import { getVariableIdsByCatalogPath } from "../db/model/Variable.js" +import { ExplorerGrammar } from "../explorer/ExplorerGrammar.js" +import { + CoreTable, + ErrorValueTypes, + isNotErrorValueOrEmptyCell, +} from "@ourworldindata/core-table" +import { ColumnGrammar } from "../explorer/ColumnGrammar.js" +import { ColumnTypeNames } from "@ourworldindata/types" + +export const transformExplorerProgramToResolveCatalogPaths = async ( + program: ExplorerProgram, + knex: db.KnexReadonlyTransaction +): Promise<{ + program: ExplorerProgram + unresolvedCatalogPaths?: Set +}> => { + const { decisionMatrix } = program + const { requiredCatalogPaths } = decisionMatrix + + if (requiredCatalogPaths.size === 0) return { program } + + const catalogPathToIndicatorIdMap = await getVariableIdsByCatalogPath( + [...requiredCatalogPaths], + knex + ) + const unresolvedCatalogPaths = new Set( + [...requiredCatalogPaths].filter( + (path) => !catalogPathToIndicatorIdMap.get(path) + ) + ) + + const colSlugsToUpdate = + decisionMatrix.allColumnsWithIndicatorIdsOrCatalogPaths.map( + (col) => col.slug + ) + // In the decision matrix table, replace any catalog paths with their corresponding indicator ids + // If a catalog path is not found, it will be left as is + const newDecisionMatrixTable = + decisionMatrix.tableWithOriginalColumnNames.replaceCells( + colSlugsToUpdate, + (val) => { + if (typeof val === "string") { + const vals = val.split(" ") + const updatedVals = vals.map( + (val) => + catalogPathToIndicatorIdMap.get(val)?.toString() ?? + val + ) + return updatedVals.join(" ") + } + return val + } + ) + + // Write the result to the "graphers" block + const grapherBlockLine = program.getRowMatchingWords( + ExplorerGrammar.graphers.keyword + ) + if (grapherBlockLine === -1) + throw new Error( + `"graphers" block not found in explorer ${program.slug}` + ) + const newProgram = program.updateBlock( + grapherBlockLine, + newDecisionMatrixTable.toMatrix() + ) + + // Next, we also need to update the "columns" block of the explorer + program.columnDefsByTableSlug.forEach((_columnDefs, tableSlug) => { + const lineNoInProgram = newProgram.getRowMatchingWords( + ExplorerGrammar.columns.keyword, + tableSlug + ) + // This should, in theory, never happen because columnDefsByTableSlug gets generated from such a block + if (lineNoInProgram === -1) + throw new Error( + `Column defs not found for explorer ${program.slug} and table ${tableSlug}` + ) + const columnDefTable = new CoreTable( + newProgram.getBlock(lineNoInProgram) + ) + const newColumnDefsTable = columnDefTable.combineColumns( + [ + ColumnGrammar.variableId.keyword, + ColumnGrammar.catalogPath.keyword, + ], + { + slug: ColumnGrammar.variableId.keyword, + type: ColumnTypeNames.Integer, + }, + (row) => { + const variableId = row[ColumnGrammar.variableId.keyword] + if (isNotErrorValueOrEmptyCell(variableId)) return variableId + + const catalogPath = row[ColumnGrammar.catalogPath.keyword] + if ( + isNotErrorValueOrEmptyCell(catalogPath) && + typeof catalogPath === "string" + ) { + return ( + catalogPathToIndicatorIdMap.get(catalogPath) ?? + ErrorValueTypes.NoMatchingVariableId + ) + } + return ErrorValueTypes.NoMatchingVariableId + } + ) + newProgram.updateBlock(lineNoInProgram, newColumnDefsTable.toMatrix()) + }) + + return { program: newProgram.clone, unresolvedCatalogPaths } +} export const bakeAllPublishedExplorers = async ( outputFolder: string, @@ -58,8 +171,10 @@ export const bakeAllExplorerRedirects = async ( ) } const html = await renderExplorerPage(program, knex, { - explorerUrlMigrationId: migrationId, - baseQueryStr, + urlMigrationSpec: { + explorerUrlMigrationId: migrationId, + baseQueryStr, + }, }) await write(path.join(outputFolder, `${redirectPath}.html`), html) } diff --git a/baker/siteRenderers.tsx b/baker/siteRenderers.tsx index 555b31b3be5..40fc5d4ee18 100644 --- a/baker/siteRenderers.tsx +++ b/baker/siteRenderers.tsx @@ -91,6 +91,7 @@ import { getAndLoadGdocBySlug, getAndLoadGdocById, } from "../db/model/Gdoc/GdocFactory.js" +import { transformExplorerProgramToResolveCatalogPaths } from "./ExplorerBaker.js" export const renderToHtmlPage = (element: any) => `${ReactDOMServer.renderToStaticMarkup(element)}` @@ -691,12 +692,34 @@ export const renderReusableBlock = async ( return cheerioEl("body").html() ?? undefined } +interface ExplorerRenderOpts { + urlMigrationSpec?: ExplorerPageUrlMigrationSpec + isPreviewing?: boolean +} + export const renderExplorerPage = async ( program: ExplorerProgram, knex: KnexReadonlyTransaction, - urlMigrationSpec?: ExplorerPageUrlMigrationSpec + opts?: ExplorerRenderOpts ) => { - const { requiredGrapherIds, requiredVariableIds } = program.decisionMatrix + const transformResult = await transformExplorerProgramToResolveCatalogPaths( + program, + knex + ) + const { program: transformedProgram, unresolvedCatalogPaths } = + transformResult + if (unresolvedCatalogPaths?.size) { + const errMessage = new JsonError( + `${unresolvedCatalogPaths.size} catalog paths cannot be found for explorer ${transformedProgram.slug}: ${[...unresolvedCatalogPaths].join(", ")}.` + ) + if (opts?.isPreviewing) console.error(errMessage) + else void logErrorAndMaybeSendToBugsnag(errMessage) + } + + // This needs to run after transformExplorerProgramToResolveCatalogPaths, so that the catalog paths + // have already been resolved and all the required grapher and variable IDs are available + const { requiredGrapherIds, requiredVariableIds } = + transformedProgram.decisionMatrix type ChartRow = { id: number; config: string } let grapherConfigRows: ChartRow[] = [] @@ -726,7 +749,7 @@ export const renderExplorerPage = async ( if (missingIds.length > 0) { void logErrorAndMaybeSendToBugsnag( new JsonError( - `Referenced variable IDs do not exist in the database for explorer ${program.slug}: ${missingIds.join(", ")}.` + `Referenced variable IDs do not exist in the database for explorer ${transformedProgram.slug}: ${missingIds.join(", ")}.` ) ) } @@ -758,10 +781,13 @@ export const renderExplorerPage = async ( return mergePartialGrapherConfigs(etlConfig, adminConfig) }) - const wpContent = program.wpBlockId + const wpContent = transformedProgram.wpBlockId ? await renderReusableBlock( - await getBlockContentFromSnapshot(knex, program.wpBlockId), - program.wpBlockId, + await getBlockContentFromSnapshot( + knex, + transformedProgram.wpBlockId + ), + transformedProgram.wpBlockId, knex ) : undefined @@ -772,10 +798,11 @@ export const renderExplorerPage = async ( ) ) diff --git a/db/model/Variable.ts b/db/model/Variable.ts index 60c96de1d9d..af2588be4b7 100644 --- a/db/model/Variable.ts +++ b/db/model/Variable.ts @@ -19,6 +19,7 @@ import { DimensionProperty, GrapherInterface, DbRawVariable, + VariablesTableName, } from "@ourworldindata/types" import { knexRaw } from "../db.js" @@ -578,3 +579,23 @@ export interface VariableResultView { table: string shortName: string } + +export const getVariableIdsByCatalogPath = async ( + catalogPaths: string[], + knex: db.KnexReadonlyTransaction +): Promise> => { + const rows: Pick[] = await knex + .select("id", "catalogPath") + .from(VariablesTableName) + .whereIn("catalogPath", catalogPaths) + + const rowsByPath = _.keyBy(rows, "catalogPath") + + // `rowsByPath` only contains the rows that were found, so we need to create + // a map where all keys from `catalogPaths` are present, and set the value to + // undefined if no row was found for that catalog path. + return new Map( + // Sort for good measure and determinism. + catalogPaths.sort().map((path) => [path, rowsByPath[path]?.id ?? null]) + ) +} diff --git a/explorer/ColumnGrammar.ts b/explorer/ColumnGrammar.ts index 44fccfb9a76..41e5852305f 100644 --- a/explorer/ColumnGrammar.ts +++ b/explorer/ColumnGrammar.ts @@ -8,6 +8,7 @@ import { ToleranceStrategy } from "@ourworldindata/utils" import { BooleanCellDef, EnumCellDef, + EtlPathCellDef, Grammar, IntegerCellDef, NumericCellDef, @@ -22,6 +23,11 @@ export const ColumnGrammar: Grammar = { keyword: "variableId", description: "Numerical variable ID", }, + catalogPath: { + ...EtlPathCellDef, + keyword: "catalogPath", + description: "Catalog path to the etl indicator", + }, slug: { ...SlugDeclarationCellDef, keyword: "slug", diff --git a/explorer/Explorer.scss b/explorer/Explorer.scss index 731ab139297..b9024002de0 100644 --- a/explorer/Explorer.scss +++ b/explorer/Explorer.scss @@ -9,6 +9,11 @@ $explorer-padding: 0.5rem; width: 100%; position: relative; padding: $explorer-padding; + + .admin-only-locally-edited-checkbox { + position: absolute; + right: 5px; + } } html.IsInIframe #ExplorerContainer { @@ -17,6 +22,10 @@ html.IsInIframe #ExplorerContainer { max-height: none; // leave some padding for shadows padding: 3px; + + .admin-only-locally-edited-checkbox { + display: none; + } } .ExplorerHeaderBox { diff --git a/explorer/Explorer.tsx b/explorer/Explorer.tsx index 1613dab6f50..70635d73e4d 100644 --- a/explorer/Explorer.tsx +++ b/explorer/Explorer.tsx @@ -41,10 +41,12 @@ import { keyBy, keyMap, omitUndefinedValues, + parseIntOrUndefined, PromiseCache, PromiseSwitcher, SerializedGridProgram, setWindowUrl, + Tippy, uniq, uniqBy, Url, @@ -53,7 +55,7 @@ import { MarkdownTextWrap, Checkbox } from "@ourworldindata/components" import classNames from "classnames" import { action, computed, observable, reaction } from "mobx" import { observer } from "mobx-react" -import React from "react" +import React, { useCallback, useEffect, useState } from "react" import ReactDOM from "react-dom" import { ExplorerControlBar, ExplorerControlPanel } from "./ExplorerControls.js" import { ExplorerProgram } from "./ExplorerProgram.js" @@ -91,26 +93,89 @@ export interface ExplorerProps extends SerializedGridProgram { selection?: SelectionArray } -const renderLivePreviewVersion = (props: ExplorerProps) => { - let renderedVersion: string - setInterval(() => { - const versionToRender = - localStorage.getItem(UNSAVED_EXPLORER_DRAFT + props.slug) ?? - props.program - if (versionToRender === renderedVersion) return - - const newProps = { ...props, program: versionToRender } - ReactDOM.render( +const LivePreviewComponent = (props: ExplorerProps) => { + const [useLocalStorage, setUseLocalStorage] = useState(true) + const [renderedProgram, setRenderedProgram] = useState("") + const [hasLocalStorage, setHasLocalStorage] = useState(false) + + const updateProgram = useCallback(() => { + const localStorageProgram = localStorage.getItem( + UNSAVED_EXPLORER_DRAFT + props.slug + ) + let program: string + if (useLocalStorage) program = localStorageProgram ?? props.program + else program = props.program + + setHasLocalStorage(!!localStorageProgram) + setRenderedProgram((previousProgram) => { + if (program === previousProgram) return previousProgram + return program + }) + }, [props.program, props.slug, useLocalStorage]) + + useEffect(() => { + updateProgram() + const interval = setInterval(updateProgram, 1000) + return () => clearInterval(interval) + }, [updateProgram]) + + const newProps = { ...props, program: renderedProgram } + + return ( + <> + {hasLocalStorage && ( +
+ +

+ Checked: Use the explorer version + with changes as present in the admin. +

+

+ Unchecked: Use the currently-saved + version. +

+
+

+ Note that some features may only work + correctly when this checkbox is unchecked: + in particular, using catalogPaths + and grapherIds and variable IDs. +

+ + } + placement="bottom" + > + +
+
+ )} , - document.getElementById(ExplorerContainerId) - ) - renderedVersion = versionToRender - }, 1000) + /> + + ) +} + +const renderLivePreviewVersion = (props: ExplorerProps) => { + ReactDOM.render( + , + document.getElementById(ExplorerContainerId) + ) } const isNarrow = () => @@ -501,8 +566,8 @@ export class Explorer const yVariableIdsList = yVariableIds .split(" ") - .map((item) => parseInt(item, 10)) - .filter((item) => !isNaN(item)) + .map(parseIntOrUndefined) + .filter((item) => item !== undefined) const partialGrapherConfig = this.partialGrapherConfigsByVariableId.get(yVariableIdsList[0]) ?? @@ -533,22 +598,28 @@ export class Explorer }) }) if (xVariableId) { - dimensions.push({ - variableId: xVariableId, - property: DimensionProperty.x, - }) + const maybeXVariableId = parseIntOrUndefined(xVariableId) + if (maybeXVariableId !== undefined) + dimensions.push({ + variableId: maybeXVariableId, + property: DimensionProperty.x, + }) } if (colorVariableId) { - dimensions.push({ - variableId: colorVariableId, - property: DimensionProperty.color, - }) + const maybeColorVariableId = parseIntOrUndefined(colorVariableId) + if (maybeColorVariableId !== undefined) + dimensions.push({ + variableId: maybeColorVariableId, + property: DimensionProperty.color, + }) } if (sizeVariableId) { - dimensions.push({ - variableId: sizeVariableId, - property: DimensionProperty.size, - }) + const maybeSizeVariableId = parseIntOrUndefined(sizeVariableId) + if (maybeSizeVariableId !== undefined) + dimensions.push({ + variableId: maybeSizeVariableId, + property: DimensionProperty.size, + }) } // Slugs that are used to create a chart refer to columns derived from variables diff --git a/explorer/ExplorerDecisionMatrix.ts b/explorer/ExplorerDecisionMatrix.ts index 2f2f454df5b..6535e8145b7 100644 --- a/explorer/ExplorerDecisionMatrix.ts +++ b/explorer/ExplorerDecisionMatrix.ts @@ -5,6 +5,7 @@ import { identity, trimObject, uniq, + parseIntOrUndefined, } from "@ourworldindata/utils" import { ColumnTypeNames } from "@ourworldindata/types" import { @@ -93,15 +94,17 @@ export class DecisionMatrix { slug: GrapherGrammar.grapherId.keyword, type: ColumnTypeNames.Integer, }, - // yVariableIds can either be a single integer or multiple integers - // separated by a whitespace. if the first row is a single integer, - // then the column type is automatically inferred to be numeric and - // rows with multiple integers are parsed incorrectly. to avoid this, - // we explicitly set the column type to be string. - { - slug: GrapherGrammar.yVariableIds.keyword, - type: ColumnTypeNames.String, - }, + // yVariableIds, xVariableIds, etc. can either be an indicator ID or a catalog path. + // If the first row contains a numeric value, the column type is inferred to be + // numeric, and parsing may fail if subsequent rows contain non-numeric values. + // In addition, yVariableIds may also contain a space-separated list of multiple + // indicator IDs or catalog paths. + ...DecisionMatrix.allColumnSlugsWithIndicatorIdsOrCatalogPaths.map( + (slug) => ({ + slug, + type: ColumnTypeNames.String, + }) + ), ]) this.hash = hash this.setValuesFromChoiceParams() // Initialize options @@ -141,6 +144,49 @@ export class DecisionMatrix { ) } + private static allColumnSlugsWithIndicatorIdsOrCatalogPaths = [ + GrapherGrammar.yVariableIds.keyword, + GrapherGrammar.xVariableId.keyword, + GrapherGrammar.colorVariableId.keyword, + GrapherGrammar.sizeVariableId.keyword, + ] + + get allColumnsWithIndicatorIdsOrCatalogPaths() { + return this.table + .getColumns( + DecisionMatrix.allColumnSlugsWithIndicatorIdsOrCatalogPaths + ) + .filter((col) => !col.isMissing) + } + + get requiredCatalogPaths(): Set { + const allIndicators = this.allColumnsWithIndicatorIdsOrCatalogPaths + .flatMap((col) => col.uniqValues) + .flatMap((value) => value.split(" ")) + .filter((value) => value !== "") + + // Assume it's a catalog path if it doesn't look like a number + const catalogPaths = allIndicators.filter( + (indicator) => parseIntOrUndefined(indicator) === undefined + ) + + return new Set(catalogPaths) + } + + // This is, basically, the inverse of `dropColumnTypes`. + // Turns a column named "Metric" back into "Metric Dropdown", for example. + get tableWithOriginalColumnNames() { + return this.table.renameColumns( + Object.fromEntries( + [...this.choiceNameToControlTypeMap.entries()].map( + ([choiceName, controlType]) => { + return [choiceName, `${choiceName} ${controlType}`] + } + ) + ) + ) + } + choiceNameToControlTypeMap: Map hash: string diff --git a/explorer/ExplorerProgram.ts b/explorer/ExplorerProgram.ts index f0a8a355db1..8a73ffa4cfb 100644 --- a/explorer/ExplorerProgram.ts +++ b/explorer/ExplorerProgram.ts @@ -2,7 +2,6 @@ import { CoreMatrix, ColumnTypeNames, CoreTableInputOption, - CoreValueType, OwidColumnDef, TableSlug, SubNavId, @@ -53,9 +52,9 @@ interface ExplorerGrapherInterface extends GrapherInterface { grapherId?: number tableSlug?: string yVariableIds?: string - xVariableId?: number - colorVariableId?: number - sizeVariableId?: number + xVariableId?: string + colorVariableId?: string + sizeVariableId?: string yScaleToggle?: boolean yAxisMin?: number facetYDomain?: FacetAxisDomain @@ -475,33 +474,31 @@ export const trimAndParseObject = (config: any, grammar: Grammar) => { } const parseColumnDefs = (block: string[][]): OwidColumnDef[] => { + /** + * A column def line can have: + * - a column named `variableId`, which contains a variable id + * - a column named `slug`, which is the referenced column in its data file + * + * We want to filter out any rows that contain neither of those, and we also + * want to rename `variableId` to `owidVariableId`. + */ const columnsTable = new CoreTable(block) .appendColumnsIfNew([ { slug: "slug", type: ColumnTypeNames.String, name: "slug" }, { slug: "variableId", - type: ColumnTypeNames.Numeric, + type: ColumnTypeNames.Integer, name: "variableId", }, ]) .renameColumn("variableId", "owidVariableId") - .combineColumns( - ["slug", "owidVariableId"], - { - slug: "slugOrVariableId", - type: ColumnTypeNames.String, - name: "slugOrVariableId", - }, - (values) => values.slug || values.owidVariableId?.toString() - ) - .columnFilter( - "slugOrVariableId", - (value: CoreValueType) => !!value, + // Filter out rows that neither have a slug nor an owidVariableId + .rowFilter( + (row) => !!(row.slug || typeof row.owidVariableId === "number"), "Keep only column defs with a slug or variable id" ) - .dropColumns(["slugOrVariableId"]) return columnsTable.rows.map((row) => { - // ignore slug if a variable id is given + // ignore slug if variable id is given if ( row.owidVariableId && isNotErrorValue(row.owidVariableId) && diff --git a/explorer/GrapherGrammar.ts b/explorer/GrapherGrammar.ts index 9d2ecb212b0..28bcac3521c 100644 --- a/explorer/GrapherGrammar.ts +++ b/explorer/GrapherGrammar.ts @@ -16,11 +16,12 @@ import { Grammar, IntegerCellDef, NumericCellDef, - PositiveIntegersCellDef, SlugDeclarationCellDef, SlugsDeclarationCellDef, StringCellDef, UrlCellDef, + IndicatorIdsOrEtlPathsCellDef, + IndicatorIdOrEtlPathCellDef, } from "../gridLang/GridLangConstants.js" const toTerminalOptions = (keywords: string[]): CellDef[] => { @@ -50,9 +51,9 @@ export const GrapherGrammar: Grammar = { keyword: "ySlugs", }, yVariableIds: { - ...PositiveIntegersCellDef, + ...IndicatorIdsOrEtlPathsCellDef, keyword: "yVariableIds", - description: "Variable ID(s) for the yAxis", + description: "Variable ID(s) or ETL path(s) for the yAxis", }, type: { ...StringCellDef, @@ -93,9 +94,9 @@ export const GrapherGrammar: Grammar = { keyword: "xSlug", }, xVariableId: { - ...IntegerCellDef, + ...IndicatorIdOrEtlPathCellDef, keyword: "xVariableId", - description: "Variable ID for the xAxis", + description: "Variable ID or ETL path for the xAxis", }, colorSlug: { ...SlugDeclarationCellDef, @@ -103,9 +104,9 @@ export const GrapherGrammar: Grammar = { keyword: "colorSlug", }, colorVariableId: { - ...IntegerCellDef, + ...IndicatorIdOrEtlPathCellDef, keyword: "colorVariableId", - description: "Variable ID for the color", + description: "Variable ID or ETL path for the color", }, sizeSlug: { ...SlugDeclarationCellDef, @@ -113,9 +114,10 @@ export const GrapherGrammar: Grammar = { keyword: "sizeSlug", }, sizeVariableId: { - ...IntegerCellDef, + ...IndicatorIdOrEtlPathCellDef, keyword: "sizeVariableId", - description: "Variable ID for the size of points on scatters", + description: + "Variable ID or ETL path for the size of points on scatters", }, tableSlugs: { ...SlugsDeclarationCellDef, diff --git a/explorerAdminClient/ExplorerCreatePage.scss b/explorerAdminClient/ExplorerCreatePage.scss index 8531a6727ee..633454ef06c 100644 --- a/explorerAdminClient/ExplorerCreatePage.scss +++ b/explorerAdminClient/ExplorerCreatePage.scss @@ -133,6 +133,10 @@ color: #00823f; } + .IndicatorIdOrEtlPath { + color: olive; + } + .CommentType { color: #999; font-style: italic; diff --git a/gridLang/GridLangConstants.ts b/gridLang/GridLangConstants.ts index 4a0cd4257d9..1fa69f782ab 100644 --- a/gridLang/GridLangConstants.ts +++ b/gridLang/GridLangConstants.ts @@ -182,6 +182,30 @@ export const SlugsDeclarationCellDef: CellDef = { requirementsDescription: `Can only contain the characters a-zA-Z0-9-_ `, } +export const EtlPathCellDef: CellDef = { + keyword: "", + cssClass: "IndicatorIdOrEtlPath", + description: "Path to an ETL indicator.", + regex: /^$|^[\w\d_/\-]+#[\w\d_/\-]+$/, + requirementsDescription: `Can only contain the characters a-zA-Z0-9-_/#`, +} + +export const IndicatorIdOrEtlPathCellDef: CellDef = { + keyword: "", + cssClass: "IndicatorIdOrEtlPath", + description: "A single indicator ID or a path to an ETL indicator.", + regex: /^\d+|[\w\d_/\-]+#[\w\d_/\-]+$/, + requirementsDescription: `Can only contain the characters a-zA-Z0-9-_/#`, +} + +export const IndicatorIdsOrEtlPathsCellDef: CellDef = { + keyword: "", + cssClass: "IndicatorIdOrEtlPath", + description: "One or more indicator IDs or paths to an ETL indicator.", + regex: /^((\d+|[\w\d_/\-]*#[\w\d_/\-]+)( +|$))+$/, + requirementsDescription: `Can only contain the characters a-zA-Z0-9-_/# `, +} + export const JSONObjectCellDef: CellDef = { keyword: "", cssClass: "JSONObjectCellDef", diff --git a/packages/@ourworldindata/core-table/src/ErrorValues.ts b/packages/@ourworldindata/core-table/src/ErrorValues.ts index 7f0600d123f..d9998140fb0 100644 --- a/packages/@ourworldindata/core-table/src/ErrorValues.ts +++ b/packages/@ourworldindata/core-table/src/ErrorValues.ts @@ -28,6 +28,7 @@ class FilteredValue extends ErrorValue {} class NoValueForInterpolation extends ErrorValue {} class InvalidQuarterValue extends ErrorValue {} class InvalidNegativeValue extends ErrorValue {} +class NoMatchingVariableId extends ErrorValue {} // todo: if we don't export this, get an error in Transforms. should be fixable, see: https://github.com/microsoft/TypeScript/issues/5711 export class MissingValuePlaceholder extends ErrorValue {} @@ -53,6 +54,7 @@ export const ErrorValueTypes = { NoValueForInterpolation: new NoValueForInterpolation(), InvalidQuarterValue: new InvalidQuarterValue(), InvalidNegativeValue: new InvalidNegativeValue(), + NoMatchingVariableId: new NoMatchingVariableId(), } // https://github.com/robertmassaioli/ts-is-present diff --git a/site/ExplorerPage.tsx b/site/ExplorerPage.tsx index 2551cf88da2..f3c2a86fcbc 100644 --- a/site/ExplorerPage.tsx +++ b/site/ExplorerPage.tsx @@ -30,6 +30,7 @@ interface ExplorerPageSettings { partialGrapherConfigs: GrapherInterface[] baseUrl: string urlMigrationSpec?: ExplorerPageUrlMigrationSpec + isPreviewing?: boolean } const ExplorerContent = ({ content }: { content: string }) => {