diff --git a/adminSiteClient/AbstractChartEditor.ts b/adminSiteClient/AbstractChartEditor.ts index 86ba3884def..52f0516a1de 100644 --- a/adminSiteClient/AbstractChartEditor.ts +++ b/adminSiteClient/AbstractChartEditor.ts @@ -8,7 +8,9 @@ import { import { action, computed, observable, when } from "mobx" import { EditorFeatures } from "./EditorFeatures.js" import { Admin } from "./Admin.js" -import { defaultGrapherConfig, Grapher } from "@ourworldindata/grapher" +import { Grapher } from "@ourworldindata/grapher" + +const defaultGrapherObject = Grapher.defaultObject() export type EditorTab = | "basic" @@ -86,7 +88,7 @@ export abstract class AbstractChartEditor< } @computed get liveConfigWithDefaults(): GrapherInterface { - return mergeGrapherConfigs(defaultGrapherConfig, this.liveConfig) + return mergeGrapherConfigs(defaultGrapherObject, this.liveConfig) } /** full config: patch config merged with parent config */ @@ -105,7 +107,7 @@ export abstract class AbstractChartEditor< | undefined { if (!this.activeParentConfig) return undefined return mergeGrapherConfigs( - defaultGrapherConfig, + defaultGrapherObject, this.activeParentConfig ) } @@ -114,7 +116,7 @@ export abstract class AbstractChartEditor< @computed get patchConfig(): GrapherInterface { return diffGrapherConfigs( this.liveConfigWithDefaults, - this.activeParentConfigWithDefaults ?? defaultGrapherConfig + this.activeParentConfigWithDefaults ?? defaultGrapherObject ) } diff --git a/adminSiteClient/ChartEditorPage.tsx b/adminSiteClient/ChartEditorPage.tsx index b0c1baa3331..207c69fc882 100644 --- a/adminSiteClient/ChartEditorPage.tsx +++ b/adminSiteClient/ChartEditorPage.tsx @@ -57,6 +57,7 @@ export class ChartEditorPage ) this.parentConfig = parent?.config this.isInheritanceEnabled = parent?.isActive ?? true + if (this.parentConfig) this.parentConfig.title = "parent title" } else if (grapherConfig) { const parentIndicatorId = getParentVariableIdFromChartConfig(grapherConfig) diff --git a/adminSiteServer/app.test.tsx b/adminSiteServer/app.test.tsx index 4062e43d3c8..d06f09f636e 100644 --- a/adminSiteServer/app.test.tsx +++ b/adminSiteServer/app.test.tsx @@ -249,36 +249,22 @@ describe("OwidAdminApp", () => { )?.config expect(parentConfig).toBeUndefined() - // fetch the full config and verify it's merged with the default + // fetch the full config and verify that id, version and isPublished are added const fullConfig = await fetchJsonFromAdminApi( `/charts/${chartId}.config.json` ) - expect(fullConfig).toHaveProperty( - "$schema", - "https://files.ourworldindata.org/schemas/grapher-schema.005.json" - ) - expect(fullConfig).toHaveProperty("id", chartId) // must match the db id - expect(fullConfig).toHaveProperty("version", 1) // automatically added - expect(fullConfig).toHaveProperty("isPublished", false) // automatically added - expect(fullConfig).toHaveProperty("slug", "test-chart") - expect(fullConfig).toHaveProperty("title", "Test chart") - expect(fullConfig).toHaveProperty("type", "LineChart") // default property - expect(fullConfig).toHaveProperty("tab", "chart") // default property + expect(fullConfig).toEqual({ + ...testChartConfig, + id: chartId, // must match the db id + version: 1, // automatically added + isPublished: false, // automatically added + }) - // fetch the patch config and verify it's diffed correctly + // fetch the patch config and verify it's identical to the full config const patchConfig = await fetchJsonFromAdminApi( `/charts/${chartId}.patchConfig.json` ) - expect(patchConfig).toEqual({ - $schema: - "https://files.ourworldindata.org/schemas/grapher-schema.005.json", - id: chartId, - version: 1, - isPublished: false, - slug: "test-chart", - title: "Test chart", - // note that the type is not included - }) + expect(patchConfig).toEqual(fullConfig) }) it("should be able to create a GDoc article", async () => { @@ -620,7 +606,7 @@ describe("OwidAdminApp: indicator-level chart configs", () => { expect(parentConfig).toEqual(mergedGrapherConfig) // fetch the full config of the chart and verify that it's been merged - // with the indicator config and the default config + // with the indicator config const fullConfig = await fetchJsonFromAdminApi( `/charts/${chartId}.config.json` ) @@ -632,7 +618,6 @@ describe("OwidAdminApp: indicator-level chart configs", () => { expect(fullConfig).toHaveProperty("note", "Indicator note") // inherited from variable expect(fullConfig).toHaveProperty("hasMapTab", true) // inherited from variable expect(fullConfig).toHaveProperty("subtitle", "Admin subtitle") // inherited from variable - expect(fullConfig).toHaveProperty("tab", "chart") // default value // fetch the patch config and verify it's diffed correctly const patchConfig = await fetchJsonFromAdminApi( diff --git a/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts b/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts index 2226665d24c..91a73cae713 100644 --- a/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts +++ b/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts @@ -2,7 +2,8 @@ import { BASE_FONT_SIZE } from "../core/GrapherConstants" import { extend, trimObject, - deleteRuntimeAndUnchangedProps, + deleteRuntimeProps, + deleteUnchangedProps, Persistable, AxisAlign, Position, @@ -94,7 +95,39 @@ export class AxisConfig domainValues: this.domainValues, }) as AxisConfigInterface - deleteRuntimeAndUnchangedProps(obj, new AxisConfigDefaults()) + deleteRuntimeProps(obj, new AxisConfigDefaults()) + deleteUnchangedProps(obj, new AxisConfigDefaults()) + + if (obj.min === undefined) obj.min = AxisMinMaxValueStr.auto + if (obj.max === undefined) obj.max = AxisMinMaxValueStr.auto + + return obj + } + + toDefaultObject(): AxisConfigInterface { + const obj = trimObject({ + orient: this.orient, + min: this.min, + max: this.max, + canChangeScaleType: this.canChangeScaleType, + removePointsOutsideDomain: this.removePointsOutsideDomain, + minSize: this.minSize, + hideAxis: this.hideAxis, + hideGridlines: this.hideGridlines, + hideTickLabels: this.hideTickLabels, + labelPadding: this.labelPadding, + nice: this.nice, + maxTicks: this.maxTicks, + tickFormattingOptions: this.tickFormattingOptions, + scaleType: this.scaleType, + label: this.label ? this.label : undefined, + facetDomain: this.facetDomain, + ticks: this.ticks, + singleValueAxisPointAlign: this.singleValueAxisPointAlign, + domainValues: this.domainValues, + }) as AxisConfigInterface + + deleteRuntimeProps(obj, new AxisConfigDefaults()) if (obj.min === undefined) obj.min = AxisMinMaxValueStr.auto if (obj.max === undefined) obj.max = AxisMinMaxValueStr.auto diff --git a/packages/@ourworldindata/grapher/src/chart/ChartDimension.ts b/packages/@ourworldindata/grapher/src/chart/ChartDimension.ts index 370cc103273..f56a29ec0ac 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartDimension.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartDimension.ts @@ -8,7 +8,7 @@ import { DimensionProperty, OwidVariableId, Persistable, - deleteRuntimeAndUnchangedProps, + deleteUnchangedProps, updatePersistables, OwidVariableDisplayConfig, OwidChartDimensionInterface, @@ -65,7 +65,7 @@ export class ChartDimension toObject(): OwidChartDimensionInterface { return trimObject( - deleteRuntimeAndUnchangedProps( + deleteUnchangedProps( { property: this.property, variableId: this.variableId, @@ -77,6 +77,15 @@ export class ChartDimension ) } + toDefaultObject(): OwidChartDimensionInterface { + return trimObject({ + property: this.property, + variableId: this.variableId, + display: this.display, + targetYear: this.targetYear, + }) + } + // Do not persist yet, until we migrate off VariableIds @observable slug?: ColumnSlug diff --git a/packages/@ourworldindata/grapher/src/color/ColorScaleConfig.ts b/packages/@ourworldindata/grapher/src/color/ColorScaleConfig.ts index 29292ef4cb5..37547ec5c8f 100644 --- a/packages/@ourworldindata/grapher/src/color/ColorScaleConfig.ts +++ b/packages/@ourworldindata/grapher/src/color/ColorScaleConfig.ts @@ -10,7 +10,8 @@ import { extend, isEmpty, trimObject, - deleteRuntimeAndUnchangedProps, + deleteUnchangedProps, + deleteRuntimeProps, objectWithPersistablesToObject, Persistable, updatePersistables, @@ -95,7 +96,15 @@ export class ColorScaleConfig toObject(): ColorScaleConfigInterface { const obj: ColorScaleConfigInterface = objectWithPersistablesToObject(this) - deleteRuntimeAndUnchangedProps(obj, new ColorScaleConfigDefaults()) + deleteRuntimeProps(obj, new ColorScaleConfigDefaults()) + deleteUnchangedProps(obj, new ColorScaleConfigDefaults()) + return trimObject(obj) + } + + toDefaultObject(): ColorScaleConfigInterface { + const obj: ColorScaleConfigInterface = + objectWithPersistablesToObject(this) + deleteRuntimeProps(obj, new ColorScaleConfigDefaults()) return trimObject(obj) } diff --git a/packages/@ourworldindata/grapher/src/controls/settings/TableFilterToggle.tsx b/packages/@ourworldindata/grapher/src/controls/settings/TableFilterToggle.tsx index d0aefe432f1..ee6cae27c17 100644 --- a/packages/@ourworldindata/grapher/src/controls/settings/TableFilterToggle.tsx +++ b/packages/@ourworldindata/grapher/src/controls/settings/TableFilterToggle.tsx @@ -32,7 +32,7 @@ export class TableFilterToggle extends React.Component<{ @action.bound onToggle(): void { const { manager } = this.props manager.showSelectionOnlyInDataTable = - manager.showSelectionOnlyInDataTable ? undefined : true + !manager.showSelectionOnlyInDataTable } render(): React.ReactElement { diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts b/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts index c8ad4c18e69..5377c848812 100755 --- a/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts +++ b/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts @@ -78,9 +78,6 @@ it("can get dimension slots", () => { it("an empty Grapher serializes to an object that includes only the schema", () => { expect(new Grapher().toObject()).toEqual({ $schema: defaultGrapherConfig.$schema, - - // TODO: ideally, selectedEntityNames is not serialised for an empty object - selectedEntityNames: [], }) }) @@ -91,18 +88,12 @@ it("a bad chart type does not crash grapher", () => { expect(new Grapher(input).toObject()).toEqual({ ...input, $schema: defaultGrapherConfig.$schema, - - // TODO: ideally, selectedEntityNames is not serialised for an empty object - selectedEntityNames: [], }) }) it("does not preserve defaults in the object (except for the schema)", () => { expect(new Grapher({ tab: GrapherTabOption.chart }).toObject()).toEqual({ $schema: defaultGrapherConfig.$schema, - - // TODO: ideally, selectedEntityNames is not serialised for an empty object - selectedEntityNames: [], }) }) diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index f285401a030..48d5c40124e 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -43,7 +43,8 @@ import { maxTimeToJSON, timeBoundToTimeBoundString, objectWithPersistablesToObject, - deleteRuntimeAndUnchangedProps, + objectWithPersistablesToDefaultObject, + deleteUnchangedProps, updatePersistables, strToQueryParams, queryParamsToStr, @@ -66,6 +67,7 @@ import { sortBy, extractDetailsFromSyntax, omit, + deleteRuntimeProps, } from "@ourworldindata/utils" import { MarkdownTextWrap, @@ -352,54 +354,56 @@ export class Grapher { @observable.ref $schema = defaultGrapherConfig.$schema @observable.ref type = ChartTypeName.LineChart - @observable.ref id?: number = undefined + @observable.ref id?: number @observable.ref version = 1 - @observable.ref slug?: string = undefined - - // Initializing text fields with `undefined` ensures that empty strings get serialised - @observable.ref title?: string = undefined - @observable.ref subtitle: string | undefined = undefined - @observable.ref sourceDesc?: string = undefined - @observable.ref note?: string = undefined - @observable.ref variantName?: string = undefined - @observable.ref internalNotes?: string = undefined - @observable.ref originUrl?: string = undefined - - @observable hideAnnotationFieldsInTitle?: AnnotationFieldsInTitle = - undefined - @observable.ref minTime?: TimeBound = undefined - @observable.ref maxTime?: TimeBound = undefined - @observable.ref timelineMinTime?: Time = undefined - @observable.ref timelineMaxTime?: Time = undefined + @observable.ref slug?: string + + @observable.ref title?: string + @observable.ref subtitle?: string + @observable.ref sourceDesc?: string + @observable.ref note?: string + @observable.ref variantName?: string + @observable.ref internalNotes?: string + @observable.ref originUrl?: string + + @observable.ref minTime?: TimeBound // TODO + @observable.ref maxTime?: TimeBound // TODO + @observable.ref timelineMinTime?: Time // TODO + @observable.ref timelineMaxTime?: Time // TODO + @observable.ref addCountryMode = EntitySelectionMode.MultipleEntities @observable.ref stackMode = StackMode.absolute @observable.ref showNoDataArea = true - @observable.ref hideLegend?: boolean = false - @observable.ref logo?: LogoOption = undefined - @observable.ref hideLogo?: boolean = undefined - @observable.ref hideRelativeToggle? = true + @observable.ref hideLegend = false + @observable.ref logo = LogoOption.owid + @observable.ref hideLogo = false + @observable.ref hideRelativeToggle = true @observable.ref entityType = DEFAULT_GRAPHER_ENTITY_TYPE @observable.ref entityTypePlural = DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL @observable.ref facettingLabelByYVariables = "metric" - @observable.ref hideTimeline?: boolean = undefined - @observable.ref hideScatterLabels?: boolean = undefined - @observable.ref zoomToSelection?: boolean = undefined - @observable.ref showYearLabels?: boolean = undefined // Always show year in labels for bar charts + @observable.ref hideTimeline = false + @observable.ref hideScatterLabels = false + @observable.ref zoomToSelection = false + @observable.ref showYearLabels = false // Always show year in labels for bar charts @observable.ref hasChartTab = true @observable.ref hasMapTab = false @observable.ref tab = GrapherTabOption.chart - @observable.ref isPublished?: boolean = undefined - @observable.ref baseColorScheme?: ColorSchemeName = undefined - @observable.ref invertColorScheme?: boolean = undefined - @observable hideConnectedScatterLines?: boolean = undefined // Hides lines between points when timeline spans multiple years. Requested by core-econ for certain charts - @observable - scatterPointLabelStrategy?: ScatterPointLabelStrategy = undefined - @observable.ref compareEndPointsOnly?: boolean = undefined - @observable.ref matchingEntitiesOnly?: boolean = undefined + @observable.ref isPublished = false + @observable.ref baseColorScheme?: ColorSchemeName + @observable.ref invertColorScheme = false + @observable.ref hideConnectedScatterLines = false // Hides lines between points when timeline spans multiple years. Requested by core-econ for certain charts + @observable.ref scatterPointLabelStrategy = ScatterPointLabelStrategy.year + @observable.ref compareEndPointsOnly = false + @observable.ref matchingEntitiesOnly = false /** Hides the total value label that is normally displayed for stacked bar charts */ - @observable.ref hideTotalValueLabel?: boolean = undefined - @observable.ref missingDataStrategy?: MissingDataStrategy = undefined - @observable.ref showSelectionOnlyInDataTable?: boolean = undefined + @observable.ref hideTotalValueLabel = false + @observable.ref missingDataStrategy = MissingDataStrategy.auto + @observable hideAnnotationFieldsInTitle: AnnotationFieldsInTitle = { + entity: false, + time: false, + changeInPrefix: false, + } + @observable.ref hideFacetControl = false // TODO: or true??? @observable.ref xAxis = new AxisConfig(undefined, this) @observable.ref yAxis = new AxisConfig(undefined, this) @@ -407,38 +411,36 @@ export class Grapher @observable map = new MapConfig() @observable.ref dimensions: ChartDimension[] = [] - @observable ySlugs?: ColumnSlugs = undefined - @observable xSlug?: ColumnSlug = undefined - @observable colorSlug?: ColumnSlug = undefined - @observable sizeSlug?: ColumnSlug = undefined - @observable tableSlugs?: ColumnSlugs = undefined + @observable ySlugs?: ColumnSlugs + @observable xSlug?: ColumnSlug + @observable colorSlug?: ColumnSlug + @observable sizeSlug?: ColumnSlug + @observable tableSlugs?: ColumnSlugs @observable selectedEntityColors: { [entityName: string]: string | undefined } = {} - // Initializing arrays with `undefined` ensures that empty arrays get serialised - @observable selectedEntityNames?: EntityName[] = undefined - @observable excludedEntities?: number[] = undefined + @observable selectedEntityNames?: EntityName[] = [] + @observable excludedEntities?: number[] = [] /** IncludedEntities are usually empty which means use all available entities. When includedEntities is set it means "only use these entities". excludedEntities are evaluated afterwards and can still remove entities even if they were included before. */ - @observable includedEntities?: number[] = undefined - @observable comparisonLines?: ComparisonLineConfig[] = undefined // todo: Persistables? - @observable relatedQuestions?: RelatedQuestionsConfig[] = undefined // todo: Persistables? - - @observable.ref annotation?: Annotation = undefined - - @observable.ref hideFacetControl?: boolean = undefined + @observable includedEntities?: number[] = [] + @observable comparisonLines?: ComparisonLineConfig[] = [] // todo: Persistables? + @observable relatedQuestions?: RelatedQuestionsConfig[] = [] // todo: Persistables? // the desired faceting strategy, which might not be possible if we change the data - @observable selectedFacetStrategy?: FacetStrategy = undefined + @observable selectedFacetStrategy = FacetStrategy.none - @observable sortBy?: SortBy = SortBy.total - @observable sortOrder?: SortOrder = SortOrder.desc + @observable sortBy = SortBy.total + @observable sortOrder = SortOrder.desc @observable sortColumnSlug?: string + @observable.ref showSelectionOnlyInDataTable = false + @observable.ref annotation?: Annotation + @observable.ref _isInFullScreenMode = false @observable.ref windowInnerWidth?: number @@ -531,6 +533,31 @@ export class Grapher if (getGrapherInstance) getGrapherInstance(this) // todo: possibly replace with more idiomatic ref } + static defaultObject(): GrapherInterface { + const grapher = new Grapher() + const obj: GrapherInterface = objectWithPersistablesToDefaultObject( + grapher, + grapherKeysToSerialize + ) + + // TODO + // deleteRuntimeProps(obj, defaultGrapherObject) + + // TODO + // // JSON doesn't support Infinity, so we use strings instead. + // if (obj.minTime) obj.minTime = minTimeToJSON(grapher.minTime) as any + // if (obj.maxTime) obj.maxTime = maxTimeToJSON(grapher.maxTime) as any + + // if (obj.timelineMinTime) + // obj.timelineMinTime = minTimeToJSON(grapher.timelineMinTime) as any + // if (obj.timelineMaxTime) + // obj.timelineMaxTime = maxTimeToJSON(grapher.timelineMaxTime) as any + + console.log("static default", obj) + + return obj + } + toObject(): GrapherInterface { const obj: GrapherInterface = objectWithPersistablesToObject( this, @@ -539,7 +566,8 @@ export class Grapher obj.selectedEntityNames = this.selection.selectedEntityNames - deleteRuntimeAndUnchangedProps(obj, defaultObject) + deleteRuntimeProps(obj, defaultGrapherObject) + deleteUnchangedProps(obj, defaultGrapherObject) // always include the schema, even if it's the default obj.$schema = this.$schema || defaultGrapherConfig.$schema @@ -560,6 +588,35 @@ export class Grapher return obj } + // toFullObject(): GrapherInterface { + // const obj: GrapherInterface = objectWithPersistablesToObject( + // this, + // grapherKeysToSerialize + // ) + + // obj.selectedEntityNames = this.selection.selectedEntityNames + + // deleteRuntimeProps(obj, defaultGrapherObject) + + // // always include the schema, even if it's the default + // obj.$schema = this.$schema || defaultGrapherConfig.$schema + + // // JSON doesn't support Infinity, so we use strings instead. + // if (obj.minTime) obj.minTime = minTimeToJSON(this.minTime) as any + // if (obj.maxTime) obj.maxTime = maxTimeToJSON(this.maxTime) as any + + // if (obj.timelineMinTime) + // obj.timelineMinTime = minTimeToJSON(this.timelineMinTime) as any + // if (obj.timelineMaxTime) + // obj.timelineMaxTime = maxTimeToJSON(this.timelineMaxTime) as any + + // // todo: remove dimensions concept + // // if (this.legacyConfigAsAuthored?.dimensions) + // // obj.dimensions = this.legacyConfigAsAuthored.dimensions + + // return obj + // } + @action.bound downloadData(): void { if (this.manuallyProvideData) { } else if (this.owidDataset) { @@ -645,7 +702,7 @@ export class Grapher const endpointsOnly = params.endpointsOnly if (endpointsOnly !== undefined) - this.compareEndPointsOnly = endpointsOnly === "1" ? true : undefined + this.compareEndPointsOnly = endpointsOnly === "1" const region = params.region if (region !== undefined) @@ -671,7 +728,7 @@ export class Grapher // only relevant for the table if (params.showSelectionOnlyInTable) { this.showSelectionOnlyInDataTable = - params.showSelectionOnlyInTable === "1" ? true : undefined + params.showSelectionOnlyInTable === "1" } if (params.showNoDataArea) { @@ -1847,7 +1904,7 @@ export class Grapher } @computed get displayTitle(): string { - if (this.title) return this.title + if (this.title !== undefined) return this.title if (this.isReady) return this.defaultTitle return "" } @@ -1857,6 +1914,10 @@ export class Grapher return this.toObject() } + // @computed get fullObject(): GrapherInterface { + // return this.toFullObject() + // } + @computed get typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart(): ChartTypeName { // Switch to bar chart if a single year is selected. Todo: do we want to do this? @@ -3469,11 +3530,13 @@ export class Grapher @observable hideRelatedQuestion = false } -const defaultObject = objectWithPersistablesToObject( +export const defaultGrapherObject = objectWithPersistablesToObject( new Grapher(), grapherKeysToSerialize ) +console.log("default", defaultGrapherObject) + export const getErrorMessageRelatedQuestionUrl = ( question: RelatedQuestionsConfig ): string | undefined => { diff --git a/packages/@ourworldindata/grapher/src/index.ts b/packages/@ourworldindata/grapher/src/index.ts index 203bb60db3a..03395323eb2 100644 --- a/packages/@ourworldindata/grapher/src/index.ts +++ b/packages/@ourworldindata/grapher/src/index.ts @@ -54,6 +54,7 @@ export { type GrapherProgrammaticInterface, type GrapherManager, getErrorMessageRelatedQuestionUrl, + defaultGrapherObject, } from "./core/Grapher" export { GrapherAnalytics, EventCategory } from "./core/GrapherAnalytics" import fuzzysort from "fuzzysort" diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapConfig.ts b/packages/@ourworldindata/grapher/src/mapCharts/MapConfig.ts index 9a11d0b740b..53b6f100d43 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapConfig.ts +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapConfig.ts @@ -6,7 +6,8 @@ import { Persistable, updatePersistables, objectWithPersistablesToObject, - deleteRuntimeAndUnchangedProps, + deleteUnchangedProps, + deleteRuntimeProps, maxTimeBoundFromJSONOrPositiveInfinity, maxTimeToJSON, trimObject, @@ -21,13 +22,13 @@ class MapConfigDefaults { @observable columnSlug?: ColumnSlug @observable time?: number @observable timeTolerance?: number - @observable toleranceStrategy?: ToleranceStrategy - @observable hideTimeline?: boolean + @observable toleranceStrategy = ToleranceStrategy.closest + @observable hideTimeline = false @observable projection = MapProjectionName.World @observable colorScale = new ColorScaleConfig() // Show the label from colorSchemeLabels in the tooltip instead of the numeric value - @observable tooltipUseCustomLabels?: boolean = undefined + @observable tooltipUseCustomLabels = false } export type MapConfigInterface = MapConfigDefaults @@ -42,7 +43,17 @@ export class MapConfig extends MapConfigDefaults implements Persistable { toObject(): NoUndefinedValues { const obj = objectWithPersistablesToObject(this) as MapConfigInterface - deleteRuntimeAndUnchangedProps(obj, new MapConfigDefaults()) + deleteRuntimeProps(obj, new MapConfigDefaults()) + deleteUnchangedProps(obj, new MapConfigDefaults()) + + if (obj.time) obj.time = maxTimeToJSON(this.time) as any + + return trimObject(obj) + } + + toDefaultObject(): MapConfigInterface { + const obj = objectWithPersistablesToObject(this) as MapConfigInterface + deleteRuntimeProps(obj, new MapConfigDefaults()) if (obj.time) obj.time = maxTimeToJSON(this.time) as any diff --git a/packages/@ourworldindata/grapher/src/schema/grapher-schema.005.yaml b/packages/@ourworldindata/grapher/src/schema/grapher-schema.005.yaml index 79ca82b4122..04cdeb64a83 100644 --- a/packages/@ourworldindata/grapher/src/schema/grapher-schema.005.yaml +++ b/packages/@ourworldindata/grapher/src/schema/grapher-schema.005.yaml @@ -480,6 +480,7 @@ properties: sortColumnSlug: description: Sort column if sortBy is column (used by stacked bar charts and marimekko) type: string + # TODO: false instead? hideFacetControl: type: boolean default: true diff --git a/packages/@ourworldindata/utils/src/OwidVariable.ts b/packages/@ourworldindata/utils/src/OwidVariable.ts index 5ab7b5b0cb4..0e8ddff350f 100644 --- a/packages/@ourworldindata/utils/src/OwidVariable.ts +++ b/packages/@ourworldindata/utils/src/OwidVariable.ts @@ -5,7 +5,8 @@ import { Persistable, updatePersistables, objectWithPersistablesToObject, - deleteRuntimeAndUnchangedProps, + deleteUnchangedProps, + deleteRuntimeProps, } from "./persistable/Persistable.js" import { OwidVariableDataTableConfigInterface, @@ -40,10 +41,23 @@ export class OwidVariableDisplayConfig } toObject(): OwidVariableDisplayConfigDefaults { - return deleteRuntimeAndUnchangedProps( + let obj = deleteRuntimeProps( objectWithPersistablesToObject(this), new OwidVariableDisplayConfigDefaults() ) + obj = deleteUnchangedProps( + objectWithPersistablesToObject(this), + new OwidVariableDisplayConfigDefaults() + ) + return obj + } + + toDefaultObject(): OwidVariableDisplayConfigDefaults { + const obj = deleteRuntimeProps( + objectWithPersistablesToObject(this), + new OwidVariableDisplayConfigDefaults() + ) + return obj } constructor(obj?: Partial) { diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index ce77166c21b..45c95b0d498 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -270,8 +270,10 @@ export { export { type Persistable, objectWithPersistablesToObject, + objectWithPersistablesToDefaultObject, updatePersistables, - deleteRuntimeAndUnchangedProps, + deleteUnchangedProps, + deleteRuntimeProps, } from "./persistable/Persistable.js" export { PointVector } from "./PointVector.js" diff --git a/packages/@ourworldindata/utils/src/persistable/Persistable.test.ts b/packages/@ourworldindata/utils/src/persistable/Persistable.test.ts index eef0650f3e0..e7f81fe97fd 100755 --- a/packages/@ourworldindata/utils/src/persistable/Persistable.test.ts +++ b/packages/@ourworldindata/utils/src/persistable/Persistable.test.ts @@ -4,7 +4,7 @@ import { objectWithPersistablesToObject, Persistable, updatePersistables, - deleteRuntimeAndUnchangedProps, + deleteUnchangedProps, } from "./Persistable.js" import { observable } from "mobx" @@ -42,7 +42,11 @@ class GameBoyGame extends GameBoyGameDefaults implements Persistable { toObject(): GameBoyGameInterface { const obj = objectWithPersistablesToObject(this) - return deleteRuntimeAndUnchangedProps(obj, new GameBoyGame()) + return deleteUnchangedProps(obj, new GameBoyGame()) + } + + toDefaultObject(): GameBoyGameInterface { + return objectWithPersistablesToObject(this) } @observable someRuntimeProp = 5 @@ -70,6 +74,14 @@ class Character } } + toDefaultObject(): { name: string; country: string } { + const { name, country } = this + return { + name, + country, + } + } + updateFromObject(obj: CharacterInterface): void { this.name = obj.name this.country = obj.country @@ -134,7 +146,7 @@ it("can handle Infinity", () => { const game = new GameBoyGame({ players: Infinity, }) - const persisted = deleteRuntimeAndUnchangedProps( + const persisted = deleteUnchangedProps( game, new GameBoyGame({ players: -Infinity }) ) diff --git a/packages/@ourworldindata/utils/src/persistable/Persistable.ts b/packages/@ourworldindata/utils/src/persistable/Persistable.ts index 461ca42490a..3ebeb9604db 100644 --- a/packages/@ourworldindata/utils/src/persistable/Persistable.ts +++ b/packages/@ourworldindata/utils/src/persistable/Persistable.ts @@ -4,6 +4,7 @@ import { isEqual } from "../Util.js" // Any classes that the user can edit, save, and then rehydrate should implement this interface export interface Persistable { toObject(): any // This should dehydrate any runtime instances to a plain object ready to be JSON stringified + toDefaultObject(): any updateFromObject(obj: any): any // This should parse an incoming object, extend the current instance, and create new instances for any non native class types } @@ -36,6 +37,34 @@ export function objectWithPersistablesToObject( return obj as T } +export function objectWithPersistablesToDefaultObject( + objWithPersistables: T, + keysToSerialize: string[] = [] +): T { + const obj = toJS(objWithPersistables) as any + const keysSet = new Set(keysToSerialize) + Object.keys(obj).forEach((key) => { + const val = (objWithPersistables as any)[key] + const valIsPersistable = val && val.toDefaultObject + + // Delete any keys we don't want to serialize, if a keep list is provided + if (keysToSerialize.length && !keysSet.has(key)) { + delete obj[key] + return + } + + // Val is persistable, call toObject + if (valIsPersistable) obj[key] = val.toDefaultObject() + else if (Array.isArray(val)) + // Scan array for persistables and seriazile. + obj[key] = val.map((item) => + item?.toObject ? item.toDefaultObject() : item + ) + else obj[key] = val + }) + return obj as T +} + // Basically does an Object.assign, except if the target is a Persistable, will call updateFromObject on // that Persistable. It does not recurse. Will only update top level Persistables. export function updatePersistables( @@ -57,21 +86,11 @@ export function updatePersistables( // Don't persist properties that haven't changed from the defaults, and don't // keep properties not on the comparable class -export function deleteRuntimeAndUnchangedProps( - changedObj: T, - defaultObject: T -): T { +export function deleteUnchangedProps(changedObj: T, defaultObject: T): T { const obj = changedObj as any const defaultObj = defaultObject as any - const defaultKeys = new Set(Object.keys(defaultObj)) Object.keys(obj).forEach((prop) => { const key = prop as any - if (!defaultKeys.has(key)) { - // Don't persist any runtime props not in the persistable instance - delete obj[key] - return - } - const currentValue = obj[key] const defaultValue = defaultObj[key] if (isEqual(currentValue, defaultValue)) { @@ -81,3 +100,21 @@ export function deleteRuntimeAndUnchangedProps( }) return obj } + +// TODO: do this properly +// Don't persist properties that haven't changed from the defaults, and don't +// keep properties not on the comparable class +export function deleteRuntimeProps(changedObj: T, defaultObject: T): T { + const obj = changedObj as any + const defaultObj = defaultObject as any + const defaultKeys = new Set(Object.keys(defaultObj)) + Object.keys(obj).forEach((prop) => { + const key = prop as any + if (!defaultKeys.has(key)) { + // Don't persist any runtime props not in the persistable instance + delete obj[key] + return + } + }) + return obj +}