From 5ffb9ff9d9f272cb36e729367b690542d1da2b33 Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Tue, 25 Jun 2024 15:30:41 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A8=20let=20charts=20inherit=20default?= =?UTF-8?q?s=20from=20a=20root=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1720449698768-AddDefaultChartConfig.ts | 162 +++++++++++ explorer/GrapherGrammar.ts | 3 +- .../src/schema/grapher-schema.004.yaml | 12 +- .../types/src/grapherTypes/GrapherTypes.ts | 8 +- packages/@ourworldindata/utils/src/Util.ts | 4 + .../utils/src/grapherConfigUtils.test.ts | 270 ++++++++++++++++++ .../utils/src/grapherConfigUtils.ts | 99 +++++++ packages/@ourworldindata/utils/src/index.ts | 5 + 8 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 db/migration/1720449698768-AddDefaultChartConfig.ts create mode 100644 packages/@ourworldindata/utils/src/grapherConfigUtils.test.ts create mode 100644 packages/@ourworldindata/utils/src/grapherConfigUtils.ts diff --git a/db/migration/1720449698768-AddDefaultChartConfig.ts b/db/migration/1720449698768-AddDefaultChartConfig.ts new file mode 100644 index 00000000000..6061691b9d3 --- /dev/null +++ b/db/migration/1720449698768-AddDefaultChartConfig.ts @@ -0,0 +1,162 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { + diffGrapherConfigs, + GrapherInterface, + mergeGrapherConfigs, +} from "@ourworldindata/utils" + +const uuid = "d4fd6977-3dc1-11ef-8ef2-0242ac120002" + +const defaultConfig = { + $schema: "https://files.ourworldindata.org/schemas/grapher-schema.004.json", + version: 1, + isPublished: false, + type: "LineChart", + logo: "owid", + tab: "chart", + hasChartTab: true, + hasMapTab: false, + minTime: "earliest", + maxTime: "latest", + xAxis: { + scaleType: "linear", + canChangeScaleType: false, + facetDomain: "shared", + removePointsOutsideDomain: false, + }, + yAxis: { + scaleType: "linear", + canChangeScaleType: false, + facetDomain: "shared", + removePointsOutsideDomain: false, + }, + invertColorScheme: false, + colorScale: { + colorSchemeInvert: false, + binningStrategy: "ckmeans", + binningStrategyBinCount: 5, + equalSizeBins: true, + customNumericColorsActive: false, + }, + addCountryMode: "add-country", + entityType: "country or region", + entityTypePlural: "countries", + matchingEntitiesOnly: false, + missingDataStrategy: "auto", + selectedFacetStrategy: "none", + facettingLabelByYVariables: "metric", + stackMode: "absolute", + sortBy: "total", + sortOrder: "desc", + scatterPointLabelStrategy: "year", + compareEndPointsOnly: false, + zoomToSelection: false, + showYearLabels: false, + showNoDataArea: true, + hideLegend: false, + hideLogo: false, + hideTimeline: false, + hideRelativeToggle: true, + hideConnectedScatterLines: false, + hideLinesOutsideTolerance: false, + hideTotalValueLabel: false, + hideScatterLabels: false, + hideFacetControl: true, + hideAnnotationFieldsInTitle: { + entity: false, + time: false, + changeInPrefix: false, + }, + map: { + projection: "World", + time: "latest", + timeTolerance: 0, + toleranceStrategy: "closest", + colorScale: { + colorSchemeInvert: false, + binningStrategy: "ckmeans", + binningStrategyBinCount: 5, + equalSizeBins: true, + customNumericColorsActive: false, + }, + tooltipUseCustomLabels: false, + hideTimeline: false, + }, +} as GrapherInterface + +export class AddDefaultChartConfig1720449698768 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const defaultConfigJson = JSON.stringify(defaultConfig) + + // add default chart config as a new row in the chart_configs table + await queryRunner.query( + `-- sql + INSERT INTO chart_configs (id, patchConfig, config) VALUES (UUID_TO_BIN(?, 1), ?, ?) + `, + [uuid, defaultConfigJson, defaultConfigJson] + ) + + const charts = (await queryRunner.query( + `-- sql + SELECT id, uuid, config FROM chart_configs + WHERE uuid != ? + `, + [uuid] + )) as { id: string; uuid: string; config: string }[] + + for (const chart of charts) { + const originalConfig = JSON.parse(chart.config) + + // if the schema is missing, we assume it's the current one + if (!originalConfig["$schema"]) { + originalConfig["$schema"] = + "https://files.ourworldindata.org/schemas/grapher-schema.004.json" + } + + const patchConfig = diffGrapherConfigs( + originalConfig, + defaultConfig + ) + const fullConfig = mergeGrapherConfigs( + defaultConfig, + originalConfig + ) + + await queryRunner.query( + `-- sql + UPDATE chart_configs + SET + patchConfig = ?, + config = ? + WHERE id = ? + `, + [ + JSON.stringify(patchConfig), + JSON.stringify(fullConfig), + chart.id, + ] + ) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `-- sql + DELETE FROM chart_configs + WHERE uuid = ? + `, + [uuid] + ) + + // recover the original configs from the charts table + await queryRunner.query( + `-- sql + UPDATE chart_configs cc + JOIN charts c + SET + cc.patchConfig = c.config, + cc.config = c.config + ` + ) + } +} diff --git a/explorer/GrapherGrammar.ts b/explorer/GrapherGrammar.ts index 28bcac3521c..ed67b32c649 100644 --- a/explorer/GrapherGrammar.ts +++ b/explorer/GrapherGrammar.ts @@ -224,8 +224,7 @@ export const GrapherGrammar: Grammar = { hideRelativeToggle: { ...BooleanCellDef, keyword: "hideRelativeToggle", - description: - "Whether to hide the relative mode UI toggle. Default depends on the chart type.", + description: "Whether to hide the relative mode UI toggle", }, timelineMinTime: { ...IntegerCellDef, diff --git a/packages/@ourworldindata/grapher/src/schema/grapher-schema.004.yaml b/packages/@ourworldindata/grapher/src/schema/grapher-schema.004.yaml index 3d4042a5586..1d51b63eff6 100644 --- a/packages/@ourworldindata/grapher/src/schema/grapher-schema.004.yaml +++ b/packages/@ourworldindata/grapher/src/schema/grapher-schema.004.yaml @@ -57,7 +57,6 @@ $defs: baseColorScheme: type: string description: One of the predefined base color schemes - default: default enum: - YlGn - YlGnBu @@ -259,7 +258,9 @@ properties: - earliest columnSlug: # TODO: remove this once we have a convention of using the first y dimension instead - description: "Column to show in the map tab. Can be a column slug (e.g. in explorers) or a variable ID (as string)." + description: | + Column to show in the map tab. Can be a column slug (e.g. in explorers) or a variable ID (as string). + If not provided, the first y dimension is used. type: string additionalProperties: false maxTime: @@ -282,7 +283,6 @@ properties: - string baseColorScheme: type: string - default: default description: The default color scheme if no color overrides are specified yAxis: $ref: "#/$defs/axis" @@ -357,7 +357,8 @@ properties: description: Reverse the order of colors in the color scheme hideRelativeToggle: type: boolean - description: Whether to hide the relative mode UI toggle. Default depends on the chart type + default: true + description: Whether to hide the relative mode UI toggle comparisonLines: description: List of vertical comparison lines to draw type: array @@ -491,13 +492,16 @@ properties: type: integer description: Number of decimal places to show minimum: 0 + default: 2 numSignificantFigures: type: integer description: Number of significant figures to show minimum: 1 + default: 3 zeroDay: type: string description: Iso date day string for the starting date if yearIsDay is used + default: "2020-01-21" additionalProperties: false variableId: type: integer diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index 0d067710c0d..a31841a8f2d 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -509,12 +509,12 @@ export enum MapProjectionName { export interface MapConfigInterface { columnSlug?: ColumnSlug - time?: Time + time?: Time | "earliest" | "latest" timeTolerance?: number toleranceStrategy?: ToleranceStrategy hideTimeline?: boolean projection?: MapProjectionName - colorScale?: ColorScaleConfigInterface + colorScale?: Partial tooltipUseCustomLabels?: boolean } @@ -532,8 +532,8 @@ export interface GrapherInterface extends SortConfig { sourceDesc?: string note?: string hideAnnotationFieldsInTitle?: AnnotationFieldsInTitle - minTime?: TimeBound - maxTime?: TimeBound + minTime?: TimeBound | "earliest" | "latest" + maxTime?: TimeBound | "earliest" | "latest" timelineMinTime?: Time timelineMaxTime?: Time dimensions?: OwidChartDimensionInterface[] diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index 567ba543efe..e0eb33e387c 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -38,6 +38,7 @@ import { maxBy, memoize, merge, + mergeWith, min, minBy, noop, @@ -102,6 +103,7 @@ export { isNil, isNull, isNumber, + isPlainObject, isString, isUndefined, keyBy, @@ -110,6 +112,7 @@ export { maxBy, memoize, merge, + mergeWith, min, minBy, noop, @@ -1748,6 +1751,7 @@ export function filterValidStringValues( } // TODO: type this correctly once we have moved types into their own top level package +// TODO: remove in favour of the new function export function mergePartialGrapherConfigs>( ...grapherConfigs: (T | undefined)[] ): T { diff --git a/packages/@ourworldindata/utils/src/grapherConfigUtils.test.ts b/packages/@ourworldindata/utils/src/grapherConfigUtils.test.ts new file mode 100644 index 00000000000..b05f340619e --- /dev/null +++ b/packages/@ourworldindata/utils/src/grapherConfigUtils.test.ts @@ -0,0 +1,270 @@ +#! /usr/bin/env jest + +import { GrapherTabOption, MapProjectionName } from "@ourworldindata/types" +import { + mergeGrapherConfigs, + diffGrapherConfigs, +} from "./grapherConfigUtils.js" + +describe(mergeGrapherConfigs, () => { + it("merges empty configs", () => { + expect(mergeGrapherConfigs({}, {})).toEqual({}) + expect(mergeGrapherConfigs({ title: "Parent title" }, {})).toEqual({ + title: "Parent title", + }) + expect(mergeGrapherConfigs({}, { title: "Child title" })).toEqual({ + title: "Child title", + }) + }) + + it("doesn't mutate input objects", () => { + const parentConfig = { title: "Title" } + const childConfig = { subtitle: "Subtitle" } + mergeGrapherConfigs(parentConfig, childConfig) + expect(parentConfig).toEqual({ title: "Title" }) + expect(childConfig).toEqual({ subtitle: "Subtitle" }) + }) + + it("merges two objects", () => { + expect( + mergeGrapherConfigs( + { title: "Parent title" }, + { subtitle: "Child subtitle" } + ) + ).toEqual({ + title: "Parent title", + subtitle: "Child subtitle", + }) + expect( + mergeGrapherConfigs( + { title: "Parent title" }, + { title: "Child title" } + ) + ).toEqual({ title: "Child title" }) + expect( + mergeGrapherConfigs( + { title: "Parent title", subtitle: "Parent subtitle" }, + { title: "Child title", hideRelativeToggle: true } + ) + ).toEqual({ + title: "Child title", + subtitle: "Parent subtitle", + hideRelativeToggle: true, + }) + }) + + it("merges three objects", () => { + expect( + mergeGrapherConfigs( + { title: "Parent title" }, + { subtitle: "Child subtitle" }, + { note: "Grandchild note" } + ) + ).toEqual({ + title: "Parent title", + subtitle: "Child subtitle", + note: "Grandchild note", + }) + expect( + mergeGrapherConfigs( + { + title: "Parent title", + subtitle: "Parent subtitle", + sourceDesc: "Parent sources", + }, + { title: "Child title", subtitle: "Child subtitle" }, + { title: "Grandchild title", note: "Grandchild note" } + ) + ).toEqual({ + title: "Grandchild title", + subtitle: "Child subtitle", + note: "Grandchild note", + sourceDesc: "Parent sources", + }) + }) + + it("merges nested objects", () => { + expect( + mergeGrapherConfigs( + { + map: { + projection: MapProjectionName.World, + time: 2000, + }, + }, + { + map: { + projection: MapProjectionName.Africa, + hideTimeline: true, + }, + } + ) + ).toEqual({ + map: { + projection: MapProjectionName.Africa, + time: 2000, + hideTimeline: true, + }, + }) + }) + + it("overwrites arrays", () => { + expect( + mergeGrapherConfigs( + { selectedEntityNames: ["France", "Italy"] }, + { selectedEntityNames: ["Italy", "Spain"] } + ) + ).toEqual({ + selectedEntityNames: ["Italy", "Spain"], + }) + expect( + mergeGrapherConfigs( + { colorScale: { customNumericValues: [1, 2] } }, + { colorScale: { customNumericValues: [3, 4] } } + ) + ).toEqual({ + colorScale: { customNumericValues: [3, 4] }, + }) + }) + + it("doesn't mutate input objects", () => { + const parentConfig = { title: "Title" } + const childConfig = { + title: "Title overwrite", + subtitle: "Subtitle", + } + mergeGrapherConfigs(parentConfig, childConfig) + expect(parentConfig).toEqual({ title: "Title" }) + expect(childConfig).toEqual({ + title: "Title overwrite", + subtitle: "Subtitle", + }) + }) + + it("doesn't merge configs of different schema versions", () => { + expect(() => + mergeGrapherConfigs( + { $schema: "1", title: "Title A" }, + { $schema: "2", title: "Title B" } + ) + ).toThrowError() + }) + + it("doesn't overwrite id, slug, version and isPublished", () => { + expect( + mergeGrapherConfigs( + { id: 1, slug: "parent-slug", version: 1, title: "Title A" }, + { title: "Title B" } + ) + ).toEqual({ title: "Title B" }) + expect( + mergeGrapherConfigs( + { id: 1, slug: "parent-slug", version: 1, title: "Title A" }, + { slug: "child-slug", title: "Title B" } + ) + ).toEqual({ slug: "child-slug", title: "Title B" }) + }) +}) + +describe(diffGrapherConfigs, () => { + it("drops redundant entries", () => { + expect( + diffGrapherConfigs( + { tab: GrapherTabOption.chart, title: "Chart" }, + { tab: GrapherTabOption.chart, title: "Reference chart" } + ) + ).toEqual({ title: "Chart" }) + expect( + diffGrapherConfigs( + { tab: GrapherTabOption.map }, + { tab: GrapherTabOption.map } + ) + ).toEqual({}) + }) + + it("diffs nested configs correctly", () => { + expect( + diffGrapherConfigs( + { + title: "Chart", + tab: GrapherTabOption.chart, + map: { + projection: MapProjectionName.World, + hideTimeline: true, + }, + }, + { + title: "Reference chart", + tab: GrapherTabOption.chart, + map: { + projection: MapProjectionName.World, + hideTimeline: false, + }, + } + ) + ).toEqual({ title: "Chart", map: { hideTimeline: true } }) + expect( + diffGrapherConfigs( + { + tab: GrapherTabOption.chart, + map: { + projection: MapProjectionName.World, + hideTimeline: true, + }, + }, + { + tab: GrapherTabOption.chart, + map: { + projection: MapProjectionName.World, + hideTimeline: true, + }, + } + ) + ).toEqual({}) + }) + + it("strips undefined values from the config", () => { + expect( + diffGrapherConfigs( + { + tab: GrapherTabOption.chart, + title: "Chart", + subtitle: undefined, + }, + { tab: GrapherTabOption.chart, title: "Reference chart" } + ) + ).toEqual({ title: "Chart" }) + }) + + it("excludes $schema, id, version, slug and isPublished", () => { + expect( + diffGrapherConfigs( + { + title: "Chart", + $schema: + "https://files.ourworldindata.org/schemas/grapher-schema.004.json", + id: 20, + version: 1, + slug: "slug", + isPublished: false, + }, + { + title: "Chart", + $schema: + "https://files.ourworldindata.org/schemas/grapher-schema.004.json", + id: 20, + version: 1, + slug: "slug", + isPublished: false, + } + ) + ).toEqual({ + $schema: + "https://files.ourworldindata.org/schemas/grapher-schema.004.json", + id: 20, + version: 1, + slug: "slug", + isPublished: false, + }) + }) +}) diff --git a/packages/@ourworldindata/utils/src/grapherConfigUtils.ts b/packages/@ourworldindata/utils/src/grapherConfigUtils.ts new file mode 100644 index 00000000000..389ba20c490 --- /dev/null +++ b/packages/@ourworldindata/utils/src/grapherConfigUtils.ts @@ -0,0 +1,99 @@ +import { GrapherInterface } from "@ourworldindata/types" +import { + isEqual, + mergeWith, + uniq, + omit, + isPlainObject, + isEmpty, + NoUndefinedValues, + omitUndefinedValues, +} from "./Util" + +const KEYS_EXCLUDED_FROM_INHERITANCE = [ + "$schema", + "id", + "slug", + "version", + "isPublished", +] + +export function mergeGrapherConfigs( + ...grapherConfigs: GrapherInterface[] +): GrapherInterface { + // abort if the grapher configs have different schema versions + const uniqueSchemas = uniq(grapherConfigs.map((c) => c["$schema"])) + if (uniqueSchemas.length > 1) { + throw new Error( + `Can't merge grapher configs with different schema versions: ${uniqueSchemas.join( + ", " + )}` + ) + } + + // keys that should not be inherited are removed from all but the last config + const cleanedConfigs = grapherConfigs.map((config, index) => { + if (index === grapherConfigs.length - 1) return config + return omit(config, KEYS_EXCLUDED_FROM_INHERITANCE) + }) + + return mergeWith( + {}, // mergeWith mutates the first argument + ...cleanedConfigs, + (_: unknown, childValue: unknown): any => { + // don't concat arrays, just use the last one + if (Array.isArray(childValue)) { + return childValue + } + } + ) +} + +function omitUndefinedValuesRecursive>( + obj: TObj +): NoUndefinedValues { + const result: any = {} + for (const key in obj) { + const isNonEmptyObject = + isPlainObject(obj[key]) && !isEmpty(omitUndefinedValues(obj[key])) + if (isNonEmptyObject) { + result[key] = omitUndefinedValuesRecursive(obj[key]) + } else if ( + obj[key] !== undefined && + (!isPlainObject(obj[key]) || isNonEmptyObject) + ) { + result[key] = obj[key] + } + } + return result +} + +function traverseObjects>( + obj: TObj, + ref: Record, + cb: (objValue: unknown, refValue: unknown, key: string) => unknown +): Partial { + const result: any = {} + for (const key in obj) { + if (isPlainObject(obj[key]) && isPlainObject(ref[key])) { + result[key] = traverseObjects(obj[key], ref[key], cb) + } else { + result[key] = cb(obj[key], ref[key], key) + } + } + return result +} + +export function diffGrapherConfigs( + config: GrapherInterface, + reference: GrapherInterface +): GrapherInterface { + return omitUndefinedValuesRecursive( + traverseObjects(config, reference, (value, refValue, key) => { + if (KEYS_EXCLUDED_FROM_INHERITANCE.includes(key)) return value + if (refValue === undefined) return value + if (!isEqual(value, refValue)) return value + else return undefined + }) + ) +} diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index 1d0d4559a85..7dd328dd516 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -331,3 +331,8 @@ export { } from "./DonateUtils.js" export { isAndroid, isIOS } from "./BrowserUtils.js" + +export { + diffGrapherConfigs, + mergeGrapherConfigs, +} from "./grapherConfigUtils.js"