diff --git a/db/migration/1720449698768-AddDefaultChartConfig.ts b/db/migration/1720449698768-AddDefaultChartConfig.ts new file mode 100644 index 00000000000..54c9cbab243 --- /dev/null +++ b/db/migration/1720449698768-AddDefaultChartConfig.ts @@ -0,0 +1,165 @@ +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, + }, + baseColorScheme: "default", + invertColorScheme: false, + colorScale: { + baseColorScheme: "default", + 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: { + baseColorScheme: "default", + colorSchemeInvert: false, + binningStrategy: "ckmeans", + binningStrategyBinCount: 5, + equalSizeBins: true, + customNumericColorsActive: false, + }, + tooltipUseCustomLabels: false, + hideTimeline: false, + }, +} as unknown as GrapherInterface // TODO: type + +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/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"