From ab919cda9af7298d6d26a9298820f98a24a6029d Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Fri, 19 Jul 2024 10:09:16 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20(grapher)=20make=20charts=20inhe?= =?UTF-8?q?rit=20indicator-level=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminSiteClient/AdminApp.tsx | 12 + adminSiteClient/ChartEditor.ts | 250 ++++++--- adminSiteClient/ChartEditorContext.ts | 7 + adminSiteClient/ChartEditorPage.tsx | 489 +---------------- adminSiteClient/ChartEditorView.tsx | 516 ++++++++++++++++++ adminSiteClient/DimensionCard.tsx | 9 +- adminSiteClient/EditorBasicTab.tsx | 31 +- adminSiteClient/EditorCustomizeTab.tsx | 29 +- adminSiteClient/EditorDataTab.tsx | 9 +- adminSiteClient/EditorExportTab.tsx | 11 +- adminSiteClient/EditorFeatures.tsx | 6 +- adminSiteClient/EditorHistoryTab.tsx | 3 +- adminSiteClient/EditorMapTab.tsx | 5 +- adminSiteClient/EditorReferencesTab.tsx | 7 +- adminSiteClient/EditorTextTab.tsx | 42 +- adminSiteClient/IndicatorChartEditorPage.tsx | 72 +++ adminSiteClient/SaveButtons.tsx | 68 ++- adminSiteClient/VariableSelector.tsx | 16 +- adminSiteServer/apiRouter.ts | 227 ++++++-- baker/GrapherBaker.tsx | 4 +- baker/siteRenderers.tsx | 16 +- ...veIndicatorChartsToTheChartConfigsTable.ts | 184 +++++++ db/model/Chart.ts | 49 +- db/model/ChartConfigs.ts | 68 +++ db/model/Variable.ts | 334 +++++++++++- packages/@ourworldindata/grapher/src/index.ts | 1 + .../src/schema/grapher-schema.004.yaml | 3 +- .../types/src/dbTypes/InheritingCharts.ts | 8 + .../types/src/dbTypes/Variables.ts | 43 +- packages/@ourworldindata/types/src/index.ts | 4 - packages/@ourworldindata/utils/src/Util.ts | 20 + packages/@ourworldindata/utils/src/index.ts | 1 + 32 files changed, 1818 insertions(+), 726 deletions(-) create mode 100644 adminSiteClient/ChartEditorContext.ts create mode 100644 adminSiteClient/ChartEditorView.tsx create mode 100644 adminSiteClient/IndicatorChartEditorPage.tsx create mode 100644 db/migration/1721296631522-MoveIndicatorChartsToTheChartConfigsTable.ts create mode 100644 db/model/ChartConfigs.ts create mode 100644 packages/@ourworldindata/types/src/dbTypes/InheritingCharts.ts diff --git a/adminSiteClient/AdminApp.tsx b/adminSiteClient/AdminApp.tsx index 56804458a34..014ae1609f1 100644 --- a/adminSiteClient/AdminApp.tsx +++ b/adminSiteClient/AdminApp.tsx @@ -41,6 +41,7 @@ import { BulkGrapherConfigEditorPage } from "./BulkGrapherConfigEditor.js" import { GdocsIndexPage, GdocsMatchProps } from "./GdocsIndexPage.js" import { GdocsPreviewPage } from "./GdocsPreviewPage.js" import { GdocsStoreProvider } from "./GdocsStore.js" +import { IndicatorChartEditorPage } from "./IndicatorChartEditorPage.js" @observer class AdminErrorMessage extends React.Component<{ admin: Admin }> { @@ -154,6 +155,17 @@ export class AdminApp extends React.Component<{ path="/charts" component={ChartIndexPage} /> + ( + + )} + /> { - return ( - references.postsWordpress.length + - references.postsGdocs.length + - references.explorers.length - ) -} - export interface Namespace { name: string description?: string @@ -70,7 +60,6 @@ export interface Namespace { // This contains the dataset/variable metadata for the entire database // Used for variable selector interface - export interface NamespaceData { datasets: Dataset[] } @@ -97,32 +86,79 @@ export interface DimensionErrorMessage { displayName?: string } -export interface ChartEditorManager { - admin: Admin - grapher: Grapher - database: EditorDatabase - baseGrapherConfig: GrapherInterface - logs: Log[] - references: References | undefined - redirects: ChartRedirect[] - pageviews?: RawPageview - allTopics: Topic[] - details: DetailDictionary - invalidDetailReferences: DetailReferences - errorMessages: Partial> - errorMessagesForDimensions: Record< - DimensionProperty, - DimensionErrorMessage[] - > +type EditorTab = + | "basic" + | "data" + | "text" + | "customize" + | "map" + | "scatter" + | "marimekko" + | "revisions" + | "refs" + | "export" + +interface Variable { + id: number + name: string } -interface VariableIdUsageRecord { - variableId: number - usageCount: number +export interface Dataset { + id: number + name: string + namespace: string + version: string | undefined + variables: Variable[] + isPrivate: boolean + nonRedistributable: boolean } -export class ChartEditor { - manager: ChartEditorManager +export interface Log { + userId: number + userName: string + config: Json + createdAt: string +} + +export interface References { + postsWordpress: PostReference[] + postsGdocs: PostReference[] + explorers: string[] +} + +export const getFullReferencesCount = (references: References): number => { + return ( + references.postsWordpress.length + + references.postsGdocs.length + + references.explorers.length + ) +} + +export interface Namespace { + name: string + description?: string + isArchived: boolean +} + +// This contains the dataset/variable metadata for the entire database +// Used for variable selector interface +export interface NamespaceData { + datasets: Dataset[] +} + +export interface DimensionErrorMessage { + displayName?: string +} + +export interface AbstractChartEditorManager { + admin: Admin + grapher: Grapher +} + +export abstract class AbstractChartEditor< + Manager extends AbstractChartEditorManager = AbstractChartEditorManager, +> { + manager: Manager // Whether the current chart state is saved or not @observable.ref currentRequest: Promise | undefined @observable.ref tab: EditorTab = "basic" @@ -130,12 +166,11 @@ export class ChartEditor { @observable.ref previewMode: "mobile" | "desktop" @observable.ref showStaticPreview = false @observable.ref savedPatchConfig: GrapherInterface = {} + @observable.ref parentConfig: GrapherInterface = {} - // This gets set when we save a new chart for the first time - // so the page knows to update the url - @observable.ref newChartId?: number + private cachedParentConfigs = new Map() - constructor(props: { manager: ChartEditorManager }) { + constructor(props: { manager: Manager }) { this.manager = props.manager this.previewMode = localStorage.getItem("editorPreviewMode") === "mobile" @@ -145,16 +180,56 @@ export class ChartEditor { () => this.grapher.isReady, () => (this.savedPatchConfig = this.patchConfig) ) + + reaction( + () => this.parentIndicatorId, + async () => { + if (!this.parentIndicatorId) { + this.parentConfig = {} + console.log( + "no parent", + this.parentIndicatorId, + this.parentConfig + ) + return + } + if (this.cachedParentConfigs.has(this.parentIndicatorId)) { + this.parentConfig = this.cachedParentConfigs.get( + this.parentIndicatorId + )! + console.log( + "cached parent", + this.parentIndicatorId, + this.parentConfig + ) + return + } + this.parentConfig = await this.manager.admin.getJSON( + `/api/variables/mergedGrapherConfig/${this.parentIndicatorId}.json` + ) + this.cachedParentConfigs.set( + this.parentIndicatorId, + this.parentConfig + ) + console.log( + "new parent", + this.parentIndicatorId, + this.parentConfig + ) + } + ) } @computed get fullConfig(): GrapherInterface { return this.grapher.object } + @computed get parentIndicatorId(): number | undefined { + return getParentIndicatorIdFromChartConfig(this.fullConfig) + } + @computed get patchConfig(): GrapherInterface { - const { baseGrapherConfig } = this.manager - if (!baseGrapherConfig) return this.fullConfig - return diffGrapherConfigs(this.fullConfig, baseGrapherConfig) + return diffGrapherConfigs(this.fullConfig, this.parentConfig) } @computed get isModified(): boolean { @@ -168,10 +243,29 @@ export class ChartEditor { return this.manager.grapher } - @computed get database() { - return this.manager.database + @computed get features(): EditorFeatures { + return new EditorFeatures(this) } + abstract get isNewGrapher(): boolean + abstract get availableTabs(): EditorTab[] + + abstract saveGrapher(props?: { onError?: () => void }): Promise +} + +export interface ChartEditorManager extends AbstractChartEditorManager { + logs: Log[] + references: References | undefined + redirects: ChartRedirect[] + pageviews?: RawPageview + allTopics: Topic[] +} + +export class ChartEditor extends AbstractChartEditor { + // This gets set when we save a new chart for the first time + // so the page knows to update the url + @observable.ref newChartId?: number + @computed get logs() { return this.manager.logs } @@ -192,10 +286,6 @@ export class ChartEditor { return this.manager.allTopics } - @computed get details() { - return this.manager.details - } - @computed get availableTabs(): EditorTab[] { const tabs: EditorTab[] = ["basic", "data", "text", "customize"] if (this.grapher.hasMapTab) tabs.push("map") @@ -211,23 +301,6 @@ export class ChartEditor { return this.grapher.id === undefined } - @computed get features() { - return new EditorFeatures(this) - } - - async loadVariableUsageCounts(): Promise { - const data = (await this.manager.admin.getJSON( - `/api/variables.usages.json` - )) as VariableIdUsageRecord[] - const finalData = new Map( - data.map(({ variableId, usageCount }: VariableIdUsageRecord) => [ - variableId, - +usageCount, - ]) - ) - runInAction(() => (this.database.variableUsageCounts = finalData)) - } - async saveGrapher({ onError, }: { onError?: () => void } = {}): Promise { @@ -312,3 +385,42 @@ export class ChartEditor { } } } + +export interface IndicatorChartEditorManager + extends AbstractChartEditorManager { + isNewGrapher: boolean +} + +export class IndicatorChartEditor extends AbstractChartEditor { + @computed get availableTabs(): EditorTab[] { + const tabs: EditorTab[] = ["basic", "data", "text", "customize"] + if (this.grapher.hasMapTab) tabs.push("map") + if (this.grapher.isScatter) tabs.push("scatter") + if (this.grapher.isMarimekko) tabs.push("marimekko") + tabs.push("export") + return tabs + } + + @computed get isNewGrapher() { + return this.manager.isNewGrapher + } + + async saveGrapher({ + onError, + }: { onError?: () => void } = {}): Promise { + // TODO(inheritance) + console.log("save indicator chart") + } +} + +export function isChartEditorInstance( + editor: AbstractChartEditor +): editor is ChartEditor { + return editor instanceof ChartEditor +} + +export function isIndicatorChartEditorInstance( + editor: AbstractChartEditor +): editor is IndicatorChartEditor { + return editor instanceof IndicatorChartEditor +} diff --git a/adminSiteClient/ChartEditorContext.ts b/adminSiteClient/ChartEditorContext.ts new file mode 100644 index 00000000000..2d56f6930b2 --- /dev/null +++ b/adminSiteClient/ChartEditorContext.ts @@ -0,0 +1,7 @@ +import { DimensionProperty } from "@ourworldindata/types" +import React from "react" + +export const ChartEditorContext = React.createContext({ + errorMessages: {}, + errorMessagesForDimensions: {}, +}) diff --git a/adminSiteClient/ChartEditorPage.tsx b/adminSiteClient/ChartEditorPage.tsx index 2b590bfa592..9c32b323fa0 100644 --- a/adminSiteClient/ChartEditorPage.tsx +++ b/adminSiteClient/ChartEditorPage.tsx @@ -1,191 +1,49 @@ import React from "react" import { observer } from "mobx-react" -import { - observable, - computed, - runInAction, - autorun, - action, - reaction, - IReactionDisposer, -} from "mobx" -import { Prompt, Redirect } from "react-router-dom" -import { - Bounds, - capitalize, - RawPageview, - DetailDictionary, - get, - set, - groupBy, - extractDetailsFromSyntax, - getIndexableKeys, -} from "@ourworldindata/utils" -import { - Topic, - GrapherInterface, - GrapherStaticFormat, - ChartRedirect, - DimensionProperty, -} from "@ourworldindata/types" +import { observable, computed, runInAction, action } from "mobx" +import { RawPageview } from "@ourworldindata/utils" +import { Topic, GrapherInterface, ChartRedirect } from "@ourworldindata/types" import { defaultGrapherConfig, Grapher } from "@ourworldindata/grapher" import { Admin } from "./Admin.js" import { ChartEditor, - EditorDatabase, Log, References, ChartEditorManager, - Dataset, - getFullReferencesCount, - DetailReferences, - FieldWithDetailReferences, } from "./ChartEditor.js" -import { EditorBasicTab } from "./EditorBasicTab.js" -import { EditorDataTab } from "./EditorDataTab.js" -import { EditorTextTab } from "./EditorTextTab.js" -import { EditorCustomizeTab } from "./EditorCustomizeTab.js" -import { EditorScatterTab } from "./EditorScatterTab.js" -import { EditorMapTab } from "./EditorMapTab.js" -import { EditorHistoryTab } from "./EditorHistoryTab.js" -import { EditorReferencesTab } from "./EditorReferencesTab.js" -import { SaveButtons } from "./SaveButtons.js" -import { LoadingBlocker } from "./Forms.js" -import { AdminLayout } from "./AdminLayout.js" import { AdminAppContext, AdminAppContextType } from "./AdminAppContext.js" -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" -import { faMobile, faDesktop } from "@fortawesome/free-solid-svg-icons" -import { - VisionDeficiency, - VisionDeficiencySvgFilters, - VisionDeficiencyDropdown, - VisionDeficiencyEntity, -} from "./VisionDeficiencies.js" -import { EditorMarimekkoTab } from "./EditorMarimekkoTab.js" -import { EditorExportTab } from "./EditorExportTab.js" -import { runDetailsOnDemand } from "../site/detailsOnDemand.js" - -@observer -class TabBinder extends React.Component<{ editor: ChartEditor }> { - dispose!: IReactionDisposer - componentDidMount(): void { - //window.addEventListener("hashchange", this.onHashChange) - this.onHashChange() +import { ChartEditorView, ChartEditorViewManager } from "./ChartEditorView.js" - this.dispose = autorun(() => { - //setTimeout(() => window.location.hash = `#${tab}-tab`, 100) - }) - } - - componentWillUnmount(): void { - //window.removeEventListener("hashchange", this.onHashChange) - this.dispose() - } - - render(): null { - return null - } - - @action.bound onHashChange(): void { - const match = window.location.hash.match(/#(.+?)-tab/) - if (match) { - const tab = match[1] - if ( - this.props.editor.grapher && - this.props.editor.availableTabs.includes(tab) - ) - this.props.editor.tab = tab - } - } -} +// TODO: what happens to parent config if indicator IDs are updated? @observer export class ChartEditorPage extends React.Component<{ grapherId?: number - newGrapherIndex?: number grapherConfig?: GrapherInterface }> - implements ChartEditorManager + implements ChartEditorManager, ChartEditorViewManager { @observable.ref grapher = new Grapher() - @observable.ref database = new EditorDatabase({}) @observable logs: Log[] = [] @observable references: References | undefined = undefined @observable redirects: ChartRedirect[] = [] @observable pageviews?: RawPageview = undefined @observable allTopics: Topic[] = [] - @observable details: DetailDictionary = {} - - @observable.ref grapherElement?: React.ReactElement static contextType = AdminAppContext context!: AdminAppContextType - @observable simulateVisionDeficiency?: VisionDeficiency - - fetchedGrapherConfig?: GrapherInterface - // for now, every chart's previous config layer is the default layer - baseGrapherConfig = defaultGrapherConfig + originalGrapherConfig: GrapherInterface = {} - async fetchGrapher(): Promise { - const { grapherId } = this.props + async fetchGrapherConfig(): Promise { + const { grapherId, grapherConfig } = this.props if (grapherId !== undefined) { - this.fetchedGrapherConfig = await this.context.admin.getJSON( + this.originalGrapherConfig = await this.context.admin.getJSON( `/api/charts/${grapherId}.config.json` ) - } - this.updateGrapher() - } - - @observable private _isDbSet = false - @observable private _isGrapherSet = false - @computed get isReady(): boolean { - return this._isDbSet && this._isGrapherSet - } - - @action.bound private updateGrapher(): void { - const config = this.fetchedGrapherConfig ?? this.props.grapherConfig - - const grapherConfig = { - ...config, - // binds the grapher instance to this.grapher - getGrapherInstance: (grapher: Grapher) => { - this.grapher = grapher - }, - dataApiUrlForAdmin: - this.context.admin.settings.DATA_API_FOR_ADMIN_UI, // passed this way because clientSettings are baked and need a recompile to be updated - bounds: this.bounds, - staticFormat: this.staticFormat, - } - this.grapher.renderToStatic = !!this.editor?.showStaticPreview - this.grapherElement = - this._isGrapherSet = true - } - - @action.bound private setDb(json: any): void { - this.database = new EditorDatabase(json) - this._isDbSet = true - } - - async fetchData(): Promise { - const { admin } = this.context - - const [namespaces, variables] = await Promise.all([ - admin.getJSON(`/api/editorData/namespaces.json`), - admin.getJSON(`/api/editorData/variables.json`), - ]) - - this.setDb(namespaces) - - const groupedByNamespace = groupBy( - variables.datasets, - (d) => d.namespace - ) - for (const namespace in groupedByNamespace) { - this.database.dataByNamespace.set(namespace, { - datasets: groupedByNamespace[namespace] as Dataset[], - }) + } else if (grapherConfig) { + this.originalGrapherConfig = grapherConfig } } @@ -237,136 +95,16 @@ export class ChartEditorPage runInAction(() => (this.allTopics = json.topics)) } - async fetchDetails(): Promise { - await runDetailsOnDemand() - - runInAction(() => { - if (window.details) this.details = window.details - }) - } - - @computed private get isMobilePreview(): boolean { - return this.editor?.previewMode === "mobile" - } - - @computed private get bounds(): Bounds { - return this.isMobilePreview - ? new Bounds(0, 0, 380, 525) - : this.grapher.defaultBounds - } - - @computed private get staticFormat(): GrapherStaticFormat { - return this.isMobilePreview - ? GrapherStaticFormat.square - : GrapherStaticFormat.landscape - } - - // unvalidated terms extracted from the subtitle and note fields - // these may point to non-existent details e.g. ["not_a_real_term", "pvotery"] - @computed - get currentDetailReferences(): DetailReferences { - return { - subtitle: extractDetailsFromSyntax(this.grapher.currentSubtitle), - note: extractDetailsFromSyntax(this.grapher.note), - axisLabelX: extractDetailsFromSyntax( - this.grapher.xAxisConfig.label ?? "" - ), - axisLabelY: extractDetailsFromSyntax( - this.grapher.yAxisConfig.label ?? "" - ), - } - } - - // the actual Detail objects, indexed by category.term - @computed get currentlyReferencedDetails(): GrapherInterface["details"] { - const grapherConfigDetails: GrapherInterface["details"] = {} - const allReferences = Object.values(this.currentDetailReferences).flat() - - allReferences.forEach((term) => { - const detail = get(this.details, term) - if (detail) { - set(grapherConfigDetails, term, detail) - } - }) - - return grapherConfigDetails - } - - @computed - get invalidDetailReferences(): ChartEditorManager["invalidDetailReferences"] { - const { subtitle, note, axisLabelX, axisLabelY } = - this.currentDetailReferences - return { - subtitle: subtitle.filter((term) => !this.details[term]), - note: note.filter((term) => !this.details[term]), - axisLabelX: axisLabelX.filter((term) => !this.details[term]), - axisLabelY: axisLabelY.filter((term) => !this.details[term]), - } - } - - @computed get errorMessages(): ChartEditorManager["errorMessages"] { - const { invalidDetailReferences } = this - - const errorMessages: ChartEditorManager["errorMessages"] = {} - - // add error messages for each field with invalid detail references - getIndexableKeys(invalidDetailReferences).forEach( - (key: FieldWithDetailReferences) => { - const references = invalidDetailReferences[key] - if (references.length) { - errorMessages[key] = - `Invalid detail(s) specified: ${references.join(", ")}` - } - } - ) - - return errorMessages - } - - @computed - get errorMessagesForDimensions(): ChartEditorManager["errorMessagesForDimensions"] { - const errorMessages: ChartEditorManager["errorMessagesForDimensions"] = - { - [DimensionProperty.y]: [], - [DimensionProperty.x]: [], - [DimensionProperty.color]: [], - [DimensionProperty.size]: [], - [DimensionProperty.table]: [], // not used - } - - this.grapher.dimensionSlots.forEach((slot) => { - slot.dimensions.forEach((dimension, dimensionIndex) => { - const details = extractDetailsFromSyntax( - dimension.display.name ?? "" - ) - const hasDetailsInDisplayName = details.length > 0 - - // add error message if details are referenced in the display name - if (hasDetailsInDisplayName) { - errorMessages[slot.property][dimensionIndex] = { - displayName: "Detail syntax is not supported", - } - } - }) - }) - - return errorMessages - } - @computed get admin(): Admin { return this.context.admin } - @computed get editor(): ChartEditor | undefined { - if (!this.isReady) return undefined - + @computed get editor(): ChartEditor { return new ChartEditor({ manager: this }) } @action.bound refresh(): void { - void this.fetchGrapher() - void this.fetchDetails() - void this.fetchData() + void this.fetchGrapherConfig() void this.fetchLogs() void this.fetchRefs() void this.fetchRedirects() @@ -377,207 +115,18 @@ export class ChartEditorPage // this.fetchTopics() } - disposers: IReactionDisposer[] = [] - componentDidMount(): void { this.refresh() - - this.disposers.push( - reaction( - () => this.editor && this.editor.previewMode, - () => { - if (this.editor) { - localStorage.setItem( - "editorPreviewMode", - this.editor.previewMode - ) - } - this.updateGrapher() - } - ) - ) } + // TODO(inheritance) // This funny construction allows the "new chart" link to work by forcing an update // even if the props don't change - UNSAFE_componentWillReceiveProps(): void { - setTimeout(() => this.refresh(), 0) - } - - componentWillUnmount(): void { - this.disposers.forEach((dispose) => dispose()) - } + // UNSAFE_componentWillReceiveProps(): void { + // setTimeout(() => this.refresh(), 0) + // } render(): React.ReactElement { - return ( - -
- {(this.editor === undefined || - this.editor.currentRequest) && } - {this.editor !== undefined && this.renderReady(this.editor)} -
-
- ) - } - - renderReady(editor: ChartEditor): React.ReactElement { - const { grapher, availableTabs } = editor - - return ( - - {!editor.newChartId && ( - - )} - {editor.newChartId && ( - - )} - -
- -
- {editor.tab === "basic" && ( - - )} - {editor.tab === "text" && ( - - )} - {editor.tab === "data" && ( - - )} - {editor.tab === "customize" && ( - - )} - {editor.tab === "scatter" && ( - - )} - {editor.tab === "marimekko" && ( - - )} - {editor.tab === "map" && ( - - )} - {editor.tab === "revisions" && ( - - )} - {editor.tab === "refs" && ( - - )} - {editor.tab === "export" && ( - - )} -
- {editor.tab !== "export" && } -
-
-
- {this.grapherElement} -
-
-
- - -
-
- Emulate vision deficiency:{" "} - - (this.simulateVisionDeficiency = - option.deficiency) - )} - /> -
-
- - {/* Include svg filters necessary for vision deficiency emulation */} - -
-
- ) + return } } diff --git a/adminSiteClient/ChartEditorView.tsx b/adminSiteClient/ChartEditorView.tsx new file mode 100644 index 00000000000..7183a1ece88 --- /dev/null +++ b/adminSiteClient/ChartEditorView.tsx @@ -0,0 +1,516 @@ +import React from "react" +import { observer } from "mobx-react" +import { + observable, + computed, + runInAction, + action, + reaction, + IReactionDisposer, +} from "mobx" +import { Prompt, Redirect } from "react-router-dom" +import { + Bounds, + capitalize, + DetailDictionary, + get, + set, + groupBy, + extractDetailsFromSyntax, + getIndexableKeys, +} from "@ourworldindata/utils" +import { + GrapherInterface, + GrapherStaticFormat, + DimensionProperty, +} from "@ourworldindata/types" +import { Grapher } from "@ourworldindata/grapher" +import { Admin } from "./Admin.js" +import { + EditorDatabase, + Dataset, + getFullReferencesCount, + DetailReferences, + FieldWithDetailReferences, + isChartEditorInstance, +} from "./ChartEditor.js" +import { EditorBasicTab } from "./EditorBasicTab.js" +import { EditorDataTab } from "./EditorDataTab.js" +import { EditorTextTab } from "./EditorTextTab.js" +import { EditorCustomizeTab } from "./EditorCustomizeTab.js" +import { EditorScatterTab } from "./EditorScatterTab.js" +import { EditorMapTab } from "./EditorMapTab.js" +import { EditorHistoryTab } from "./EditorHistoryTab.js" +import { EditorReferencesTab } from "./EditorReferencesTab.js" +import { + SaveButtonsForChart, + SaveButtonsForIndicatorChart, +} from "./SaveButtons.js" +import { LoadingBlocker } from "./Forms.js" +import { AdminLayout } from "./AdminLayout.js" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" +import { faMobile, faDesktop } from "@fortawesome/free-solid-svg-icons" +import { + VisionDeficiency, + VisionDeficiencySvgFilters, + VisionDeficiencyDropdown, + VisionDeficiencyEntity, +} from "./VisionDeficiencies.js" +import { EditorMarimekkoTab } from "./EditorMarimekkoTab.js" +import { EditorExportTab } from "./EditorExportTab.js" +import { runDetailsOnDemand } from "../site/detailsOnDemand.js" +import { ChartEditorContext } from "./ChartEditorContext.js" +import { AbstractChartEditor } from "./ChartEditor.js" +import { isIndicatorChartEditorInstance } from "./ChartEditor.js" + +interface DimensionErrorMessage { + displayName?: string +} + +export interface ChartEditorViewManager { + admin: Admin + editor: Editor + grapher: Grapher + originalGrapherConfig: GrapherInterface +} + +@observer +export class ChartEditorView< + Editor extends AbstractChartEditor, +> extends React.Component<{ + manager: ChartEditorViewManager +}> { + @observable.ref database = new EditorDatabase({}) + @observable details: DetailDictionary = {} + @observable.ref grapherElement?: React.ReactElement + + @observable simulateVisionDeficiency?: VisionDeficiency + + @computed private get manager(): ChartEditorViewManager { + return this.props.manager + } + + @observable private _isDbSet = false + @observable private _isGrapherSet = false + @computed get isReady(): boolean { + return this._isDbSet && this._isGrapherSet + } + + @action.bound private updateGrapher(): void { + const config = this.manager.originalGrapherConfig + const grapherConfig = { + ...config, + // binds the grapher instance to this.grapher + getGrapherInstance: (grapher: Grapher) => { + this.manager.grapher = grapher + }, + dataApiUrlForAdmin: + this.manager.admin.settings.DATA_API_FOR_ADMIN_UI, // passed this way because clientSettings are baked and need a recompile to be updated + bounds: this.bounds, + staticFormat: this.staticFormat, + } + this.manager.grapher.renderToStatic = !!this.editor?.showStaticPreview + this.grapherElement = + this._isGrapherSet = true + } + + @action.bound private setDb(json: any): void { + this.database = new EditorDatabase(json) + this._isDbSet = true + } + + async fetchData(): Promise { + const { admin } = this.manager + + const [namespaces, variables] = await Promise.all([ + admin.getJSON(`/api/editorData/namespaces.json`), + admin.getJSON(`/api/editorData/variables.json`), + ]) + + this.setDb(namespaces) + + const groupedByNamespace = groupBy( + variables.datasets, + (d) => d.namespace + ) + for (const namespace in groupedByNamespace) { + this.database.dataByNamespace.set(namespace, { + datasets: groupedByNamespace[namespace] as Dataset[], + }) + } + + const usageData = (await admin.getJSON( + `/api/variables.usages.json` + )) as { + variableId: number + usageCount: number + }[] + this.database.variableUsageCounts = new Map( + usageData.map(({ variableId, usageCount }) => [ + variableId, + +usageCount, + ]) + ) + } + + async fetchDetails(): Promise { + await runDetailsOnDemand() + + runInAction(() => { + if (window.details) this.details = window.details + }) + } + + @computed private get isMobilePreview(): boolean { + return this.editor?.previewMode === "mobile" + } + + @computed private get bounds(): Bounds { + return this.isMobilePreview + ? new Bounds(0, 0, 380, 525) + : this.manager.grapher.defaultBounds + } + + @computed private get staticFormat(): GrapherStaticFormat { + return this.isMobilePreview + ? GrapherStaticFormat.square + : GrapherStaticFormat.landscape + } + + // unvalidated terms extracted from the subtitle and note fields + // these may point to non-existent details e.g. ["not_a_real_term", "pvotery"] + @computed + get currentDetailReferences(): DetailReferences { + const { grapher } = this.manager + return { + subtitle: extractDetailsFromSyntax(grapher.currentSubtitle), + note: extractDetailsFromSyntax(grapher.note), + axisLabelX: extractDetailsFromSyntax( + grapher.xAxisConfig.label ?? "" + ), + axisLabelY: extractDetailsFromSyntax( + grapher.yAxisConfig.label ?? "" + ), + } + } + + // the actual Detail objects, indexed by category.term + @computed get currentlyReferencedDetails(): GrapherInterface["details"] { + const grapherConfigDetails: GrapherInterface["details"] = {} + const allReferences = Object.values(this.currentDetailReferences).flat() + + allReferences.forEach((term) => { + const detail = get(this.details, term) + if (detail) { + set(grapherConfigDetails, term, detail) + } + }) + + return grapherConfigDetails + } + + @computed + get invalidDetailReferences(): DetailReferences { + const { subtitle, note, axisLabelX, axisLabelY } = + this.currentDetailReferences + return { + subtitle: subtitle.filter((term) => !this.details[term]), + note: note.filter((term) => !this.details[term]), + axisLabelX: axisLabelX.filter((term) => !this.details[term]), + axisLabelY: axisLabelY.filter((term) => !this.details[term]), + } + } + + @computed get errorMessages(): Partial< + Record + > { + const { invalidDetailReferences } = this + + const errorMessages: Partial< + Record + > = {} + + // add error messages for each field with invalid detail references + getIndexableKeys(invalidDetailReferences).forEach( + (key: FieldWithDetailReferences) => { + const references = invalidDetailReferences[key] + if (references.length) { + errorMessages[key] = + `Invalid detail(s) specified: ${references.join(", ")}` + } + } + ) + + return errorMessages + } + + @computed + get errorMessagesForDimensions(): Record< + DimensionProperty, + DimensionErrorMessage[] + > { + const errorMessages: Record< + DimensionProperty, + DimensionErrorMessage[] + > = { + [DimensionProperty.y]: [], + [DimensionProperty.x]: [], + [DimensionProperty.color]: [], + [DimensionProperty.size]: [], + [DimensionProperty.table]: [], // not used + } + + this.manager.grapher.dimensionSlots.forEach((slot) => { + slot.dimensions.forEach((dimension, dimensionIndex) => { + const details = extractDetailsFromSyntax( + dimension.display.name ?? "" + ) + const hasDetailsInDisplayName = details.length > 0 + + // add error message if details are referenced in the display name + if (hasDetailsInDisplayName) { + errorMessages[slot.property][dimensionIndex] = { + displayName: "Detail syntax is not supported", + } + } + }) + }) + + return errorMessages + } + + @computed get editor(): Editor | undefined { + if (!this.isReady) return undefined + + return this.manager.editor + } + + @action.bound refresh(): void { + void this.fetchDetails() + void this.fetchData() + } + + componentDidMount(): void { + this.refresh() + this.updateGrapher() + + this.disposers.push( + reaction( + () => this.editor && this.editor.previewMode, + () => { + if (this.editor) { + localStorage.setItem( + "editorPreviewMode", + this.editor.previewMode + ) + } + this.updateGrapher() + } + ) + ) + } + + // TODO(inheritance) + // This funny construction allows the "new chart" link to work by forcing an update + // even if the props don't change + // UNSAFE_componentWillReceiveProps(): void { + // setTimeout(() => this.refresh(), 0) + // } + + disposers: IReactionDisposer[] = [] + componentWillUnmount(): void { + this.disposers.forEach((dispose) => dispose()) + } + + render(): React.ReactElement { + return ( + +
+ {(this.editor === undefined || + this.editor.currentRequest) && } + {this.editor !== undefined && this.renderReady(this.editor)} +
+
+ ) + } + + renderReady(editor: Editor): React.ReactElement { + const { grapher, availableTabs } = editor + + const chartEditor = isChartEditorInstance(editor) ? editor : undefined + const indicatorChartEditor = isIndicatorChartEditorInstance(editor) + ? editor + : undefined + + const saveButtons = chartEditor ? ( + + ) : indicatorChartEditor ? ( + + ) : null + + return ( + + {!editor.isNewGrapher && ( + + )} + {chartEditor?.newChartId && ( + + )} +
+ +
+ {editor.tab === "basic" && ( + + )} + {editor.tab === "text" && ( + + )} + {editor.tab === "data" && ( + + )} + {editor.tab === "customize" && ( + + )} + {editor.tab === "scatter" && ( + + )} + {editor.tab === "marimekko" && ( + + )} + {editor.tab === "map" && ( + + )} + {chartEditor && chartEditor.tab === "revisions" && ( + + )} + {chartEditor && chartEditor.tab === "refs" && ( + + )} + {editor.tab === "export" && ( + + )} +
+ {editor.tab !== "export" && saveButtons} +
+
+
+ {this.grapherElement} +
+
+
+ + +
+
+ Emulate vision deficiency:{" "} + + (this.simulateVisionDeficiency = + option.deficiency) + )} + /> +
+
+ + {/* Include svg filters necessary for vision deficiency emulation */} + +
+
+ ) + } +} diff --git a/adminSiteClient/DimensionCard.tsx b/adminSiteClient/DimensionCard.tsx index cbb6ab2d9a0..842a3d508e6 100644 --- a/adminSiteClient/DimensionCard.tsx +++ b/adminSiteClient/DimensionCard.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react" import { ChartDimension } from "@ourworldindata/grapher" import { OwidVariableRoundingMode } from "@ourworldindata/types" import { startCase } from "@ourworldindata/utils" -import { ChartEditor, DimensionErrorMessage } from "./ChartEditor.js" +import { DimensionErrorMessage } from "./ChartEditor.js" import { Toggle, BindAutoString, @@ -22,11 +22,14 @@ import { } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { OwidTable } from "@ourworldindata/core-table" +import { AbstractChartEditor } from "./ChartEditor.js" @observer -export class DimensionCard extends React.Component<{ +export class DimensionCard< + Editor extends AbstractChartEditor, +> extends React.Component<{ dimension: ChartDimension - editor: ChartEditor + editor: Editor isDndEnabled?: boolean onChange: (dimension: ChartDimension) => void onEdit?: () => void diff --git a/adminSiteClient/EditorBasicTab.tsx b/adminSiteClient/EditorBasicTab.tsx index c2e4994e689..119c95c31ab 100644 --- a/adminSiteClient/EditorBasicTab.tsx +++ b/adminSiteClient/EditorBasicTab.tsx @@ -30,7 +30,6 @@ import { } from "@ourworldindata/utils" import { FieldsRow, Section, SelectField, Toggle } from "./Forms.js" import { VariableSelector } from "./VariableSelector.js" -import { ChartEditor, ChartEditorManager } from "./ChartEditor.js" import { DimensionCard } from "./DimensionCard.js" import { DragDropContext, @@ -38,12 +37,19 @@ import { Draggable, DropResult, } from "react-beautiful-dnd" +import { ChartEditorContext } from "./ChartEditorContext.js" +import { AbstractChartEditor, EditorDatabase } from "./ChartEditor.js" @observer -class DimensionSlotView extends React.Component<{ +class DimensionSlotView< + Editor extends AbstractChartEditor, +> extends React.Component<{ slot: DimensionSlot - editor: ChartEditor + editor: Editor + database: EditorDatabase }> { + static contextType = ChartEditorContext + disposers: IReactionDisposer[] = [] @observable.ref isSelectingVariables: boolean = false @@ -53,8 +59,8 @@ class DimensionSlotView extends React.Component<{ } @computed - get errorMessages(): ChartEditorManager["errorMessagesForDimensions"] { - return this.props.editor.manager.errorMessagesForDimensions + get errorMessages() { + return this.context.errorMessagesForDimensions } @action.bound private onAddVariables(variableIds: OwidVariableId[]) { @@ -267,6 +273,7 @@ class DimensionSlotView extends React.Component<{ {isSelectingVariables && ( (this.isSelectingVariables = false) @@ -280,7 +287,9 @@ class DimensionSlotView extends React.Component<{ } @observer -class VariablesSection extends React.Component<{ editor: ChartEditor }> { +class VariablesSection< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor; database: EditorDatabase }> { base: React.RefObject = React.createRef() @observable.ref isAddingVariable: boolean = false @@ -296,6 +305,7 @@ class VariablesSection extends React.Component<{ editor: ChartEditor }> { key={slot.name} slot={slot} editor={props.editor} + database={props.database} /> ))} @@ -305,7 +315,9 @@ class VariablesSection extends React.Component<{ editor: ChartEditor }> { } @observer -export class EditorBasicTab extends React.Component<{ editor: ChartEditor }> { +export class EditorBasicTab< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor; database: EditorDatabase }> { @action.bound onChartTypeChange(value: string) { const { grapher } = this.props.editor grapher.type = value as ChartTypeName @@ -371,7 +383,10 @@ export class EditorBasicTab extends React.Component<{ editor: ChartEditor }> { /> - + ) } diff --git a/adminSiteClient/EditorCustomizeTab.tsx b/adminSiteClient/EditorCustomizeTab.tsx index 7cc973c0820..c2a9d4b2081 100644 --- a/adminSiteClient/EditorCustomizeTab.tsx +++ b/adminSiteClient/EditorCustomizeTab.tsx @@ -37,6 +37,8 @@ import { } from "./ColorSchemeDropdown.js" import { EditorColorScaleSection } from "./EditorColorScaleSection.js" import Select from "react-select" +import { ChartEditorContext } from "./ChartEditorContext.js" +import { AbstractChartEditor } from "./ChartEditor.js" @observer export class ColorSchemeSelector extends React.Component<{ grapher: Grapher }> { @@ -103,7 +105,9 @@ interface SortOrderDropdownOption { } @observer -class SortOrderSection extends React.Component<{ editor: ChartEditor }> { +class SortOrderSection< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor }> { @computed get sortConfig(): SortConfig { return this.grapher._sortConfig } @@ -203,7 +207,9 @@ class SortOrderSection extends React.Component<{ editor: ChartEditor }> { } @observer -class FacetSection extends React.Component<{ editor: ChartEditor }> { +class FacetSection extends React.Component<{ + editor: Editor +}> { base: React.RefObject = React.createRef() @computed get grapher() { @@ -280,7 +286,9 @@ class FacetSection extends React.Component<{ editor: ChartEditor }> { } @observer -class TimelineSection extends React.Component<{ editor: ChartEditor }> { +class TimelineSection< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor }> { base: React.RefObject = React.createRef() @computed get grapher() { @@ -387,7 +395,9 @@ class TimelineSection extends React.Component<{ editor: ChartEditor }> { } @observer -class ComparisonLineSection extends React.Component<{ editor: ChartEditor }> { +class ComparisonLineSection< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor }> { @observable comparisonLines: ComparisonLineConfig[] = [] @action.bound onAddComparisonLine() { @@ -445,11 +455,16 @@ class ComparisonLineSection extends React.Component<{ editor: ChartEditor }> { } @observer -export class EditorCustomizeTab extends React.Component<{ - editor: ChartEditor +export class EditorCustomizeTab< + Editor extends AbstractChartEditor, +> extends React.Component<{ + editor: Editor }> { + // TODO + static contextType = ChartEditorContext + @computed get errorMessages() { - return this.props.editor.manager.errorMessages + return this.context.errorMessages } render() { diff --git a/adminSiteClient/EditorDataTab.tsx b/adminSiteClient/EditorDataTab.tsx index 14a81edf41b..225bbc0bda4 100644 --- a/adminSiteClient/EditorDataTab.tsx +++ b/adminSiteClient/EditorDataTab.tsx @@ -18,6 +18,7 @@ import { Droppable, DropResult, } from "react-beautiful-dnd" +import { AbstractChartEditor } from "./ChartEditor.js" interface EntityItemProps extends React.HTMLProps { grapher: Grapher @@ -164,7 +165,9 @@ export class KeysSection extends React.Component<{ grapher: Grapher }> { } @observer -class MissingDataSection extends React.Component<{ editor: ChartEditor }> { +class MissingDataSection< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor }> { @computed get grapher() { return this.props.editor.grapher } @@ -208,7 +211,9 @@ class MissingDataSection extends React.Component<{ editor: ChartEditor }> { } @observer -export class EditorDataTab extends React.Component<{ editor: ChartEditor }> { +export class EditorDataTab< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor }> { render() { const { editor } = this.props const { grapher, features } = editor diff --git a/adminSiteClient/EditorExportTab.tsx b/adminSiteClient/EditorExportTab.tsx index f21574c51b3..ecdc68b1fd0 100644 --- a/adminSiteClient/EditorExportTab.tsx +++ b/adminSiteClient/EditorExportTab.tsx @@ -8,6 +8,7 @@ import { triggerDownloadFromBlob, GrapherStaticFormat, } from "@ourworldindata/utils" +import { AbstractChartEditor } from "./ChartEditor.js" type ExportSettings = Required< Pick< @@ -55,18 +56,20 @@ const DEFAULT_SETTINGS: ExportSettings = { shouldIncludeDetailsInStaticExport: false, } -interface EditorExportTabProps { - editor: ChartEditor +interface EditorExportTabProps { + editor: Editor } @observer -export class EditorExportTab extends React.Component { +export class EditorExportTab< + Editor extends AbstractChartEditor, +> extends React.Component> { @observable private settings = DEFAULT_SETTINGS private originalSettings: Partial = DEFAULT_SETTINGS private originalGrapher: OriginalGrapher private disposers: IReactionDisposer[] = [] - constructor(props: EditorExportTabProps) { + constructor(props: EditorExportTabProps) { super(props) this.originalGrapher = this.grabRelevantPropertiesFromGrapher() } diff --git a/adminSiteClient/EditorFeatures.tsx b/adminSiteClient/EditorFeatures.tsx index 6156e13ba59..44d14c1ed0d 100644 --- a/adminSiteClient/EditorFeatures.tsx +++ b/adminSiteClient/EditorFeatures.tsx @@ -1,11 +1,11 @@ import { computed } from "mobx" -import { ChartEditor } from "./ChartEditor.js" +import { AbstractChartEditor } from "./ChartEditor.js" // Responsible for determining what parts of the editor should be shown, based on the // type of chart being edited export class EditorFeatures { - editor: ChartEditor - constructor(editor: ChartEditor) { + editor: AbstractChartEditor + constructor(editor: AbstractChartEditor) { this.editor = editor } diff --git a/adminSiteClient/EditorHistoryTab.tsx b/adminSiteClient/EditorHistoryTab.tsx index c59788e736f..c13f2c89ffb 100644 --- a/adminSiteClient/EditorHistoryTab.tsx +++ b/adminSiteClient/EditorHistoryTab.tsx @@ -1,12 +1,13 @@ import React from "react" import { observer } from "mobx-react" -import { ChartEditor, Log } from "./ChartEditor.js" +import { ChartEditor, isChartEditorInstance, Log } from "./ChartEditor.js" import { Section, Timeago } from "./Forms.js" import { computed, action, observable } from "mobx" import { Json, copyToClipboard } from "@ourworldindata/utils" import YAML from "yaml" import { notification, Modal } from "antd" import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued" +import { AbstractChartEditor } from "./ChartEditor.js" function LogCompareModal({ log, diff --git a/adminSiteClient/EditorMapTab.tsx b/adminSiteClient/EditorMapTab.tsx index 25e4594fee8..55a104f572c 100644 --- a/adminSiteClient/EditorMapTab.tsx +++ b/adminSiteClient/EditorMapTab.tsx @@ -12,6 +12,7 @@ import React from "react" import { ChartEditor } from "./ChartEditor.js" import { EditorColorScaleSection } from "./EditorColorScaleSection.js" import { NumberField, Section, SelectField, Toggle } from "./Forms.js" +import { AbstractChartEditor } from "./ChartEditor.js" @observer class VariableSection extends React.Component<{ @@ -161,7 +162,9 @@ class TooltipSection extends React.Component<{ mapConfig: MapConfig }> { } @observer -export class EditorMapTab extends React.Component<{ editor: ChartEditor }> { +export class EditorMapTab< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor }> { @computed get grapher() { return this.props.editor.grapher } diff --git a/adminSiteClient/EditorReferencesTab.tsx b/adminSiteClient/EditorReferencesTab.tsx index 6051c991fcb..c2f38d340d1 100644 --- a/adminSiteClient/EditorReferencesTab.tsx +++ b/adminSiteClient/EditorReferencesTab.tsx @@ -13,6 +13,7 @@ import { formatValue, ChartRedirect, } from "@ourworldindata/utils" +import { AbstractChartEditor } from "./ChartEditor.js" const BASE_URL = BAKED_GRAPHER_URL.replace(/^https?:\/\//, "") @@ -214,8 +215,10 @@ export class EditorReferencesTab extends React.Component<{ } @observer -class AddRedirectForm extends React.Component<{ - editor: ChartEditor +class AddRedirectForm< + Editor extends AbstractChartEditor, +> extends React.Component<{ + editor: Editor onSuccess: (redirect: ChartRedirect) => void }> { static contextType = AdminAppContext diff --git a/adminSiteClient/EditorTextTab.tsx b/adminSiteClient/EditorTextTab.tsx index 0a5ba8b0220..36e48125a96 100644 --- a/adminSiteClient/EditorTextTab.tsx +++ b/adminSiteClient/EditorTextTab.tsx @@ -15,7 +15,11 @@ import { observer } from "mobx-react" import React from "react" import Select from "react-select" import { TOPICS_CONTENT_GRAPH } from "../settings/clientSettings.js" -import { ChartEditor, ChartEditorManager } from "./ChartEditor.js" +import { + ChartEditor, + ChartEditorManager, + isChartEditorInstance, +} from "./ChartEditor.js" import { AutoTextField, BindAutoString, @@ -27,9 +31,15 @@ import { TextField, Toggle, } from "./Forms.js" +import { ChartEditorContext } from "./ChartEditorContext.js" +import { AbstractChartEditor } from "./ChartEditor.js" @observer -export class EditorTextTab extends React.Component<{ editor: ChartEditor }> { +export class EditorTextTab< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor }> { + static contextType = ChartEditorContext + @action.bound onSlug(slug: string) { this.props.editor.grapher.slug = slugify(slug) } @@ -74,8 +84,8 @@ export class EditorTextTab extends React.Component<{ editor: ChartEditor }> { grapher.hideAnnotationFieldsInTitle.changeInPrefix = value || undefined } - @computed get errorMessages(): ChartEditorManager["errorMessages"] { - return this.props.editor.manager.errorMessages + @computed get errorMessages() { + return this.context.errorMessages } @computed get showAnyAnnotationFieldInTitleToggle() { @@ -88,7 +98,8 @@ export class EditorTextTab extends React.Component<{ editor: ChartEditor }> { } render() { - const { grapher, references, features } = this.props.editor + const { editor } = this.props + const { grapher, features } = editor const { relatedQuestions } = grapher return ( @@ -186,15 +197,16 @@ export class EditorTextTab extends React.Component<{ editor: ChartEditor }> { placeholder={grapher.originUrlWithProtocol} helpText="The page containing this chart where more context can be found" /> - {references && - (references.postsWordpress.length > 0 || - references.postsGdocs.length > 0) && ( + {isChartEditorInstance(editor) && + editor.references && + (editor.references.postsWordpress.length > 0 || + editor.references.postsGdocs.length > 0) && (

Origin url suggestions

    {[ - ...references.postsWordpress, - ...references.postsGdocs, + ...editor.references.postsWordpress, + ...editor.references.postsGdocs, ].map((post) => (
  • {post.url}
  • ))} @@ -213,10 +225,12 @@ export class EditorTextTab extends React.Component<{ editor: ChartEditor }> { /> - + {isChartEditorInstance(editor) && ( + + )}
    {relatedQuestions.map( diff --git a/adminSiteClient/IndicatorChartEditorPage.tsx b/adminSiteClient/IndicatorChartEditorPage.tsx new file mode 100644 index 00000000000..b2c2cdd473e --- /dev/null +++ b/adminSiteClient/IndicatorChartEditorPage.tsx @@ -0,0 +1,72 @@ +import React from "react" +import { observer } from "mobx-react" +import { observable, computed, action, IReactionDisposer } from "mobx" +import { isEmpty } from "@ourworldindata/utils" +import { GrapherInterface, DimensionProperty } from "@ourworldindata/types" +import { Grapher } from "@ourworldindata/grapher" +import { Admin } from "./Admin.js" +import { AdminAppContext, AdminAppContextType } from "./AdminAppContext.js" +import { ChartEditorView, ChartEditorViewManager } from "./ChartEditorView.js" +import { + IndicatorChartEditor, + IndicatorChartEditorManager, +} from "./ChartEditor.js" + +@observer +export class IndicatorChartEditorPage + extends React.Component<{ + variableId: number + }> + implements + IndicatorChartEditorManager, + ChartEditorViewManager +{ + @observable.ref grapher = new Grapher() + + static contextType = AdminAppContext + context!: AdminAppContextType + + originalGrapherConfig: GrapherInterface = {} + + isNewGrapher = false + + async fetchGrapher(): Promise { + const { variableId } = this.props + const config = await this.context.admin.getJSON( + `/api/variables/grapherConfigAdmin/${variableId}.patchConfig.json` + ) + if (isEmpty(config)) { + this.isNewGrapher = true + this.originalGrapherConfig = { + dimensions: [{ variableId, property: DimensionProperty.y }], + } + } + } + + @computed get admin(): Admin { + return this.context.admin + } + + @computed get editor(): IndicatorChartEditor { + return new IndicatorChartEditor({ manager: this }) + } + + @action.bound refresh(): void { + void this.fetchGrapher() + } + + componentDidMount(): void { + this.refresh() + } + + // TODO(inheritance) + // This funny construction allows the "new chart" link to work by forcing an update + // even if the props don't change + // UNSAFE_componentWillReceiveProps(): void { + // setTimeout(() => this.refresh(), 0) + // } + + render(): React.ReactElement { + return + } +} diff --git a/adminSiteClient/SaveButtons.tsx b/adminSiteClient/SaveButtons.tsx index 1ed6ecc5c0c..462b164720c 100644 --- a/adminSiteClient/SaveButtons.tsx +++ b/adminSiteClient/SaveButtons.tsx @@ -1,15 +1,25 @@ import React from "react" -import { ChartEditor } from "./ChartEditor.js" +import { ChartEditor, isChartEditorInstance } from "./ChartEditor.js" import { action, computed } from "mobx" import { observer } from "mobx-react" import { isEmpty } from "@ourworldindata/utils" +import { ChartEditorContext } from "./ChartEditorContext.js" +import { AbstractChartEditor } from "./ChartEditor.js" +import { IndicatorChartEditor } from "./ChartEditor.js" @observer -export class SaveButtons extends React.Component<{ editor: ChartEditor }> { +export class SaveButtonsForChart extends React.Component<{ + editor: ChartEditor +}> { + // TODO + static contextType = ChartEditorContext + @action.bound onSaveChart() { void this.props.editor.saveGrapher() } + // TODO + @action.bound onSaveAsNew() { void this.props.editor.saveAsNewGrapher() } @@ -21,8 +31,7 @@ export class SaveButtons extends React.Component<{ editor: ChartEditor }> { } @computed get hasEditingErrors(): boolean { - const { editor } = this.props - const { errorMessages, errorMessagesForDimensions } = editor.manager + const { errorMessages, errorMessagesForDimensions } = this.context if (!isEmpty(errorMessages)) return true @@ -58,7 +67,7 @@ export class SaveButtons extends React.Component<{ editor: ChartEditor }> { disabled={isSavingDisabled} > Save as new - {" "} +
) + } +} + +@observer +export class SaveButtonsForIndicatorChart extends React.Component<{ + editor: IndicatorChartEditor +}> { + static contextType = ChartEditorContext + + @action.bound onSaveChart() { + void this.props.editor.saveGrapher() + } + + @computed get hasEditingErrors(): boolean { + const { errorMessages, errorMessagesForDimensions } = this.context + + if (!isEmpty(errorMessages)) return true + + const allErrorMessagesForDimensions = Object.values( + errorMessagesForDimensions + ).flat() + return allErrorMessagesForDimensions.some((error) => error) + } + + render() { + const { hasEditingErrors } = this + const { editor } = this.props + const { grapher } = editor + + const isSavingDisabled = grapher.hasFatalErrors || hasEditingErrors - /*return
- - {" "} - {" "} -
*/ + return ( +
+ +
+ ) } } diff --git a/adminSiteClient/VariableSelector.tsx b/adminSiteClient/VariableSelector.tsx index b146dd28e15..ff0a4adeea0 100644 --- a/adminSiteClient/VariableSelector.tsx +++ b/adminSiteClient/VariableSelector.tsx @@ -30,9 +30,11 @@ import { } from "./ChartEditor.js" import { TextField, Toggle, Modal } from "./Forms.js" import { DimensionSlot } from "@ourworldindata/grapher" +import { AbstractChartEditor } from "./ChartEditor.js" -interface VariableSelectorProps { - editor: ChartEditor +interface VariableSelectorProps { + database: EditorDatabase + editor: Editor slot: DimensionSlot onDismiss: () => void onComplete: (variableIds: OwidVariableId[]) => void @@ -49,7 +51,9 @@ interface Variable { } @observer -export class VariableSelector extends React.Component { +export class VariableSelector< + Editor extends AbstractChartEditor, +> extends React.Component> { @observable.ref chosenNamespaces: Namespace[] = [] @observable.ref searchInput?: string @observable.ref isProjection?: boolean @@ -63,7 +67,7 @@ export class VariableSelector extends React.Component { @observable rowHeight: number = 32 @computed get database(): EditorDatabase { - return this.props.editor.database + return this.props.database } @computed get searchWords(): SearchWord[] { @@ -200,7 +204,7 @@ export class VariableSelector extends React.Component { render() { const { slot } = this.props - const { database } = this.props.editor + const { database } = this.props const { searchInput, chosenVariables, @@ -498,7 +502,7 @@ export class VariableSelector extends React.Component { dispose!: IReactionDisposer base: React.RefObject = React.createRef() componentDidMount() { - void this.props.editor.loadVariableUsageCounts() + // void this.props.editor.loadVariableUsageCounts() // TODO??? this.initChosenVariablesAndNamespaces() } diff --git a/adminSiteServer/apiRouter.ts b/adminSiteServer/apiRouter.ts index 1646fe65f8a..feb6ecb3906 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -13,12 +13,14 @@ import { expectInt, isValidSlug } from "../serverUtils/serverUtil.js" import { OldChartFieldList, assignTagsForCharts, + getParentConfigForChart, getChartConfigById, getChartSlugById, getGptTopicSuggestions, getRedirectsByChartId, oldChartFieldList, setChartTags, + getParentConfigForChartFromConfig, } from "../db/model/Chart.js" import { Request } from "./authentication.js" import { @@ -26,7 +28,13 @@ import { fetchS3MetadataByPath, fetchS3DataValuesByPath, searchVariables, + getGrapherConfigsForVariable, + updateGrapherConfigAdminOfVariable, + updateGrapherConfigETLOfVariable, + updateAllChartsThatInheritFromIndicator, + getParentConfigForIndicatorChartAdmin, } from "../db/model/Variable.js" +import { updateExistingFullConfig } from "../db/model/ChartConfigs.js" import { getCanonicalUrl } from "@ourworldindata/components" import { camelCaseProperties, @@ -37,8 +45,6 @@ import { parseIntOrUndefined, DbRawPostWithGdocPublishStatus, OwidVariableWithSource, - OwidChartDimensionInterface, - DimensionProperty, TaggableType, DbChartTagJoin, pick, @@ -85,6 +91,7 @@ import { DbInsertUser, FlatTagGraph, DbRawChartConfig, + DimensionProperty, } from "@ourworldindata/types" import { defaultGrapherConfig, @@ -281,7 +288,7 @@ const saveNewChart = async ( } // compute patch and full configs - const baseConfig = defaultGrapherConfig + const baseConfig = await getParentConfigForChartFromConfig(knex, config) const patchConfig = diffGrapherConfigs(config, baseConfig) const fullConfig = mergeGrapherConfigs(baseConfig, patchConfig) @@ -343,7 +350,7 @@ const updateExistingChart = async ( } // compute patch and full configs - const baseConfig = defaultGrapherConfig + const baseConfig = await getParentConfigForChart(knex, chartId) const patchConfig = diffGrapherConfigs(config, baseConfig) const fullConfig = mergeGrapherConfigs(baseConfig, patchConfig) @@ -383,6 +390,8 @@ const saveGrapher = async ( referencedVariablesMightChange = true // if the variables a chart uses can change then we need // to update the latest country data which takes quite a long time (hundreds of ms) ) => { + console.log("saving grapher...") + // Slugs need some special logic to ensure public urls remain consistent whenever possible async function isSlugUsedInRedirect() { const rows = await db.knexRaw( @@ -625,6 +634,15 @@ getRouteWithROTransaction( async (req, res, trx) => expectChartById(trx, req.params.chartId) ) +getRouteWithROTransaction( + apiRouter, + "/charts/:chartId.parentConfig.json", + async (req, res, trx) => { + const chartId = expectInt(req.params.chartId) + return getParentConfigForChart(trx, chartId) + } +) + getRouteWithROTransaction( apiRouter, "/editorData/namespaces.json", @@ -1133,21 +1151,25 @@ getRouteWithROTransaction( const whereClause = filterSExpr?.toSql() ?? "true" const resultsWithStringGrapherConfigs = await db.knexRaw( trx, - `SELECT variables.id as id, - variables.name as name, - variables.grapherConfigAdmin as config, - d.name as datasetname, - namespaces.name as namespacename, - variables.createdAt as createdAt, - variables.updatedAt as updatedAt, - variables.description as description -FROM variables -LEFT JOIN active_datasets as d on variables.datasetId = d.id -LEFT JOIN namespaces on d.namespace = namespaces.name -WHERE ${whereClause} -ORDER BY variables.id DESC -LIMIT 50 -OFFSET ${offset.toString()}` + `-- sql + SELECT + variables.id as id, + variables.name as name, + chart_configs.patch as config, + d.name as datasetname, + namespaces.name as namespacename, + variables.createdAt as createdAt, + variables.updatedAt as updatedAt, + variables.description as description + FROM variables + LEFT JOIN active_datasets as d on variables.datasetId = d.id + LEFT JOIN namespaces on d.namespace = namespaces.name + LEFT JOIN chart_configs on variables.grapherConfigIdAdmin = chart_configs.id + WHERE ${whereClause} + ORDER BY variables.id DESC + LIMIT 50 + OFFSET ${offset.toString()} + ` ) const results = resultsWithStringGrapherConfigs.map((row: any) => ({ @@ -1156,11 +1178,14 @@ OFFSET ${offset.toString()}` })) const resultCount = await db.knexRaw<{ count: number }>( trx, - `SELECT count(*) as count -FROM variables -LEFT JOIN active_datasets as d on variables.datasetId = d.id -LEFT JOIN namespaces on d.namespace = namespaces.name -WHERE ${whereClause}` + `-- sql + SELECT count(*) as count + FROM variables + LEFT JOIN active_datasets as d on variables.datasetId = d.id + LEFT JOIN namespaces on d.namespace = namespaces.name + LEFT JOIN chart_configs on variables.grapherConfigIdAdmin = chart_configs.id + WHERE ${whereClause} + ` ) return { rows: results, numTotalRows: resultCount[0].count } } @@ -1174,14 +1199,25 @@ patchRouteWithRWTransaction( const variableIds = new Set(patchesList.map((patch) => patch.id)) const configsAndIds = await db.knexRaw< - Pick - >(trx, `SELECT id, grapherConfigAdmin FROM variables where id IN (?)`, [ - [...variableIds.values()], - ]) + Pick & { + grapherConfigAdmin: DbRawChartConfig["patch"] + } + >( + trx, + `-- sql + SELECT v.id, cc.patch AS grapherConfigAdmin + FROM variables v + LEFT JOIN chart_configs cc ON v.grapherConfigIdAdmin = cc.id + WHERE v.id IN (?) + `, + [[...variableIds.values()]] + ) const configMap = new Map( configsAndIds.map((item: any) => [ item.id, - item.grapherConfigAdmin ? JSON.parse(item.grapherConfig) : {}, + item.grapherConfigAdmin + ? JSON.parse(item.grapherConfigAdmin) + : {}, ]) ) // console.log("ids", configsAndIds.map((item : any) => item.id)) @@ -1191,11 +1227,9 @@ patchRouteWithRWTransaction( } for (const [variableId, newConfig] of configMap.entries()) { - await db.knexRaw( - trx, - `UPDATE variables SET grapherConfigAdmin = ? where id = ?`, - [JSON.stringify(newConfig), variableId] - ) + const variable = await getGrapherConfigsForVariable(trx, variableId) + if (!variable) continue + await updateGrapherConfigAdminOfVariable(trx, variable, newConfig) } return { success: true } @@ -1223,6 +1257,43 @@ getRouteWithROTransaction( } ) +getRouteWithROTransaction( + apiRouter, + "/variables/grapherConfigAdmin/:variableId.patchConfig.json", + async (req, res, trx) => { + const variableId = expectInt(req.params.variableId) + console.log("here", variableId) + const variable = await getGrapherConfigsForVariable(trx, variableId) + if (!variable) { + throw new JsonError(`Variable with id ${variableId} not found`, 500) + } + return variable.admin?.patchConfig ?? {} + } +) + +getRouteWithROTransaction( + apiRouter, + "/variables/grapherConfigAdmin/:variableId.parentConfig.json", + async (req, res, trx) => { + const variableId = expectInt(req.params.variableId) + const parentConfig = await getParentConfigForIndicatorChartAdmin( + trx, + variableId + ) + return parentConfig ?? {} + } +) + +getRouteWithROTransaction( + apiRouter, + "/variables/mergedGrapherConfig/:variableId.json", + async (req, res, trx) => { + const variableId = expectInt(req.params.variableId) + const config = await getMergedGrapherConfigForVariable(trx, variableId) + return config ?? {} + } +) + // Used in VariableEditPage getRouteWithROTransaction( apiRouter, @@ -1259,22 +1330,9 @@ getRouteWithROTransaction( await assignTagsForCharts(trx, charts) const grapherConfig = await getMergedGrapherConfigForVariable( - variableId, - trx + trx, + variableId ) - if ( - grapherConfig && - (!grapherConfig.dimensions || grapherConfig.dimensions.length === 0) - ) { - const dimensions: OwidChartDimensionInterface[] = [ - { - variableId: variableId, - property: DimensionProperty.y, - display: variable.display, - }, - ] - grapherConfig.dimensions = dimensions - } const variablesWithCharts: OwidVariableWithSource & { charts: Record @@ -1291,6 +1349,77 @@ getRouteWithROTransaction( } ) +// inserts a new config or updates an existing one +postRouteWithRWTransaction( + apiRouter, + "/variables/:variableId/grapherConfigETL/update", + async (req, res, trx) => { + const variableId = expectInt(req.params.variableId) + const variable = await getGrapherConfigsForVariable(trx, variableId) + if (!variable) { + throw new JsonError(`Variable with id ${variableId} not found`, 500) + } + await updateGrapherConfigETLOfVariable(trx, variable, req.body) + return { success: true } + } +) + +deleteRouteWithRWTransaction( + apiRouter, + "/variables/:variableId/grapherConfigETL/delete", + async (req, res, trx) => { + const variableId = expectInt(req.params.variableId) + + const variable = await getGrapherConfigsForVariable(trx, variableId) + if (!variable) { + throw new JsonError(`Variable with id ${variableId} not found`, 500) + } + + if (!variable.etl) { + throw new JsonError( + `Variable with id ${variableId} doesn't have an ETL config`, + 500 + ) + } + + // remove reference in the variables table + await db.knexRaw( + trx, + `-- sql + UPDATE variables + SET grapherConfigIdETL = NULL + WHERE id = ? + `, + [variableId] + ) + + // delete row in the chart_configs table + await db.knexRaw( + trx, + `-- sql + DELETE FROM chart_configs + WHERE id = ? + `, + [variable.etl.configId] + ) + + // update admin config if there is one + if (variable.admin) { + await updateExistingFullConfig(trx, { + configId: variable.admin.configId, + config: variable.admin.patchConfig, + }) + } + + // update all charts that inherit from the indicator + await updateAllChartsThatInheritFromIndicator(trx, variableId, { + patchConfigAdmin: variable.admin?.patchConfig, + }) + + return { success: true } + } +) + getRouteWithROTransaction( apiRouter, "/datasets.json", diff --git a/baker/GrapherBaker.tsx b/baker/GrapherBaker.tsx index 9046c9cc82c..5d77cfd9658 100644 --- a/baker/GrapherBaker.tsx +++ b/baker/GrapherBaker.tsx @@ -150,8 +150,8 @@ export async function renderDataPageV2( knex: db.KnexReadWriteTransaction ) { const grapherConfigForVariable = await getMergedGrapherConfigForVariable( - variableId, - knex + knex, + variableId ) // Only merge the grapher config on the indicator if the caller tells us to do so - // this is true for preview pages for datapages on the indicator level but false diff --git a/baker/siteRenderers.tsx b/baker/siteRenderers.tsx index b69285f95d6..c541a326bf8 100644 --- a/baker/siteRenderers.tsx +++ b/baker/siteRenderers.tsx @@ -44,11 +44,11 @@ import { JsonError, Url, IndexPost, - mergePartialGrapherConfigs, OwidGdocType, OwidGdoc, OwidGdocDataInsightInterface, DbRawPost, + mergeGrapherConfigs, } from "@ourworldindata/utils" import { extractFormattingOptions } from "../serverUtils/wordpressUtils.js" import { @@ -800,7 +800,16 @@ export const renderExplorerPage = async ( if (requiredVariableIds.length) { partialGrapherConfigRows = await knexRaw( knex, - `SELECT id, grapherConfigETL, grapherConfigAdmin FROM variables WHERE id IN (?)`, + `-- sql + SELECT + v.id, + cc_etl.patch AS grapherConfigETL, + cc_admin.patch AS grapherConfigAdmin + FROM variables v + LEFT JOIN chart_configs cc_admin ON cc_admin.id=v.grapherConfigIdAdmin + LEFT JOIN chart_configs cc_etl ON cc_etl.id=v.grapherConfigIdETL + WHERE v.id IN (?) + `, [requiredVariableIds] ) @@ -840,8 +849,7 @@ export const renderExplorerPage = async ( config: row.grapherConfigETL as string, }) : {} - // TODO(inheritance): use mergeGrapherConfigs instead - return mergePartialGrapherConfigs(etlConfig, adminConfig) + return mergeGrapherConfigs(etlConfig, adminConfig) }) const wpContent = transformedProgram.wpBlockId diff --git a/db/migration/1721296631522-MoveIndicatorChartsToTheChartConfigsTable.ts b/db/migration/1721296631522-MoveIndicatorChartsToTheChartConfigsTable.ts new file mode 100644 index 00000000000..ffcefd330e6 --- /dev/null +++ b/db/migration/1721296631522-MoveIndicatorChartsToTheChartConfigsTable.ts @@ -0,0 +1,184 @@ +import { defaultGrapherConfig } from "@ourworldindata/grapher" +import { DimensionProperty, GrapherInterface } from "@ourworldindata/types" +import { omit } from "@ourworldindata/utils" +import { MigrationInterface, QueryRunner } from "typeorm" + +export class MoveIndicatorChartsToTheChartConfigsTable1721296631522 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`-- sql + ALTER TABLE variables + ADD COLUMN grapherConfigIdAdmin binary(16) UNIQUE AFTER sort, + ADD COLUMN grapherConfigIdETL binary(16) UNIQUE AFTER grapherConfigIdAdmin, + ADD CONSTRAINT fk_variables_grapherConfigIdAdmin + FOREIGN KEY (grapherConfigIdAdmin) + REFERENCES chart_configs (id) + ON DELETE RESTRICT + ON UPDATE RESTRICT, + ADD CONSTRAINT fk_variables_grapherConfigIdETL + FOREIGN KEY (grapherConfigIdETL) + REFERENCES chart_configs (id) + ON DELETE RESTRICT + ON UPDATE RESTRICT + `) + + // note that we copy the ETL-authored configs to the chart_configs table, + // but drop the admin-authored configs + + const variables = await queryRunner.query(`-- sql + SELECT id, grapherConfigETL + FROM variables + WHERE grapherConfigETL IS NOT NULL + `) + + for (const { id: variableId, grapherConfigETL } of variables) { + let config: GrapherInterface = JSON.parse(grapherConfigETL) + + // if the config has no schema, assume it's the default version + if (!config.$schema) { + config.$schema = defaultGrapherConfig.$schema + } + + // check if the given dimensions are correct + if (config.dimensions && config.dimensions.length >= 1) { + // make sure there is only a single entry + config.dimensions = config.dimensions.slice(0, 1) + // make sure the variable id matches + config.dimensions[0].variableId = variableId + } + + // fill dimensions if not given to make the config plottable + if (!config.dimensions || config.dimensions.length === 0) { + config.dimensions = [ + { property: DimensionProperty.y, variableId }, + ] + } + + // we have v3 configs in the database (the current version is v4); + // turn these into v4 configs by removing the `data` property + // which was the breaking change that lead to v4 + // (we don't have v2 or v1 configs in the database, so we don't need to handle those) + if ( + config.$schema === + "https://files.ourworldindata.org/schemas/grapher-schema.003.json" + ) { + config = omit(config, "data") + config.$schema = defaultGrapherConfig.$schema + } + + // insert config into the chart_configs table + const configId = await getBinaryUUID(queryRunner) + await queryRunner.query( + `-- sql + INSERT INTO chart_configs (id, patch, full) + VALUES (?, ?, ?) + `, + [configId, JSON.stringify(config), JSON.stringify(config)] + ) + + // update reference in the variables table + await queryRunner.query( + `-- sql + UPDATE variables + SET grapherConfigIdETL = ? + WHERE id = ? + `, + [configId, variableId] + ) + } + + // drop `grapherConfigAdmin` and `grapherConfigETL` columns + await queryRunner.query(`-- sql + ALTER TABLE variables + DROP COLUMN grapherConfigAdmin, + DROP COLUMN grapherConfigETL + `) + + // add a view that lists all charts that inherit from an indicator + await queryRunner.query(`-- sql + CREATE VIEW inheriting_charts AS ( + WITH y_dimensions AS ( + SELECT + * + FROM + chart_dimensions + WHERE + property = 'y' + ), + single_y_indicator_charts As ( + SELECT + c.id as chartId, + cc.patch as patchConfig, + max(yd.variableId) as variableId + FROM + charts c + JOIN chart_configs cc ON cc.id = c.configId + JOIN y_dimensions yd ON c.id = yd.chartId + WHERE + cc.full ->> '$.type' != 'ScatterPlot' + GROUP BY + c.id + HAVING + COUNT(distinct yd.variableId) = 1 + ) + SELECT + variableId, + chartId + FROM + single_y_indicator_charts + ORDER BY + variableId + ) + `) + } + + public async down(queryRunner: QueryRunner): Promise { + // drop view + await queryRunner.query(`-- sql + DROP VIEW inheriting_charts + `) + + // add back the `grapherConfigAdmin` and `grapherConfigETL` columns + await queryRunner.query(`-- sql + ALTER TABLE variables + ADD COLUMN grapherConfigAdmin json AFTER sort, + ADD COLUMN grapherConfigETL json AFTER grapherConfigAdmin + `) + + // copy configs from the chart_configs table to the variables table + await queryRunner.query(`-- sql + UPDATE variables v + JOIN chart_configs cc ON v.grapherConfigIdETL = cc.id + SET v.grapherConfigETL = cc.patch + `) + + // remove constraints on the `grapherConfigIdAdmin` and `grapherConfigIdETL` columns + await queryRunner.query(`-- sql + ALTER TABLE variables + DROP CONSTRAINT fk_variables_grapherConfigIdAdmin, + DROP CONSTRAINT fk_variables_grapherConfigIdETL + `) + + // drop rows from the chart_configs table + await queryRunner.query(`-- sql + DELETE FROM chart_configs + WHERE id IN ( + SELECT grapherConfigIdETL FROM variables + WHERE grapherConfigIdETL IS NOT NULL + ) + `) + + // remove the `grapherConfigIdAdmin` and `grapherConfigIdETL` columns + await queryRunner.query(`-- sql + ALTER TABLE variables + DROP COLUMN grapherConfigIdAdmin, + DROP COLUMN grapherConfigIdETL + `) + } +} + +const getBinaryUUID = async (queryRunner: QueryRunner): Promise => { + const rows = await queryRunner.query(`SELECT UUID_TO_BIN(UUID(), 1) AS id`) + return rows[0].id +} diff --git a/db/model/Chart.ts b/db/model/Chart.ts index 86ece2b0648..f9cfc5a34c6 100644 --- a/db/model/Chart.ts +++ b/db/model/Chart.ts @@ -1,11 +1,16 @@ import * as lodash from "lodash" import * as db from "../db.js" -import { getDataForMultipleVariables } from "./Variable.js" +import { + getDataForMultipleVariables, + getGrapherConfigsForVariable, +} from "./Variable.js" import { JsonError, KeyChartLevel, MultipleOwidVariableDataDimensionsMap, DbChartTagJoin, + mergeGrapherConfigs, + getParentIndicatorIdFromChartConfig, } from "@ourworldindata/utils" import { GrapherInterface, @@ -18,7 +23,9 @@ import { DbPlainTag, DbRawChartConfig, DbEnrichedChartConfig, + DimensionProperty, } from "@ourworldindata/types" +import { defaultGrapherConfig } from "@ourworldindata/grapher" import { OpenAI } from "openai" import { BAKED_BASE_URL, @@ -232,6 +239,46 @@ export async function getChartConfigBySlug( return { id: row.id, config: parseChartConfig(row.config) } } +export async function getParentConfigForChart( + trx: db.KnexReadonlyTransaction, + chartId: number +): Promise { + // check if the chart inherits settings from an indicator + const parentVariable = await db.knexRawFirst<{ id: number }>( + trx, + `-- sql + SELECT variableId AS id + FROM inheriting_charts + WHERE chartId = ? + `, + [chartId] + ) + + // all charts inherit from the default config + if (!parentVariable) return defaultGrapherConfig + + const variable = await getGrapherConfigsForVariable(trx, parentVariable.id) + return mergeGrapherConfigs( + defaultGrapherConfig, + variable?.etl?.patchConfig ?? {}, + variable?.admin?.patchConfig ?? {} + ) +} + +export async function getParentConfigForChartFromConfig( + trx: db.KnexReadonlyTransaction, + config: GrapherInterface +): Promise { + const parentVariableId = getParentIndicatorIdFromChartConfig(config) + if (!parentVariableId) return defaultGrapherConfig + const variable = await getGrapherConfigsForVariable(trx, parentVariableId) + return mergeGrapherConfigs( + defaultGrapherConfig, + variable?.etl?.patchConfig ?? {}, + variable?.admin?.patchConfig ?? {} + ) +} + export async function setChartTags( knex: db.KnexReadWriteTransaction, chartId: number, diff --git a/db/model/ChartConfigs.ts b/db/model/ChartConfigs.ts new file mode 100644 index 00000000000..f66d3ff7eef --- /dev/null +++ b/db/model/ChartConfigs.ts @@ -0,0 +1,68 @@ +import { DbInsertChartConfig, GrapherInterface } from "@ourworldindata/types" + +import * as db from "../db.js" + +interface ConfigWithId { + configId: DbInsertChartConfig["id"] + config: GrapherInterface +} + +export async function updateExistingConfigPair( + knex: db.KnexReadWriteTransaction, + { + configId, + patchConfig, + fullConfig, + }: { + configId: DbInsertChartConfig["id"] + patchConfig: GrapherInterface + fullConfig: GrapherInterface + } +): Promise { + await db.knexRaw( + knex, + `-- sql + UPDATE chart_configs + SET + patch = ?, + full = ? + WHERE id = ? + `, + [JSON.stringify(patchConfig), JSON.stringify(fullConfig), configId] + ) +} + +export async function updateExistingPatchConfig( + knex: db.KnexReadWriteTransaction, + params: ConfigWithId +): Promise { + await updateExistingConfig(knex, { ...params, column: "patch" }) +} + +export async function updateExistingFullConfig( + knex: db.KnexReadWriteTransaction, + params: ConfigWithId +): Promise { + await updateExistingConfig(knex, { ...params, column: "full" }) +} + +export async function updateExistingConfig( + knex: db.KnexReadWriteTransaction, + { + column, + configId, + config, + }: ConfigWithId & { + column: "patch" | "full" + } +): Promise { + await db.knexRaw( + knex, + `-- sql + UPDATE chart_configs + SET ?? = ? + WHERE id = ? + `, + [column, JSON.stringify(config), configId] + ) +} diff --git a/db/model/Variable.ts b/db/model/Variable.ts index 8bcd74abe0e..aa319f29a14 100644 --- a/db/model/Variable.ts +++ b/db/model/Variable.ts @@ -1,10 +1,18 @@ import _ from "lodash" import { Writable } from "stream" import * as db from "../db.js" -import { retryPromise, isEmpty } from "@ourworldindata/utils" +import { + retryPromise, + isEmpty, + omitUndefinedValues, + mergeGrapherConfigs, + omitNullableValues, +} from "@ourworldindata/utils" import { getVariableDataRoute, getVariableMetadataRoute, + defaultGrapherConfig, + DEFAULT_GRAPHER_CONFIG_SCHEMA, } from "@ourworldindata/grapher" import pl from "nodejs-polars" import { DATA_API_URL } from "../../settings/serverSettings.js" @@ -20,33 +28,319 @@ import { GrapherInterface, DbRawVariable, VariablesTableName, + DbRawChartConfig, + parseChartConfig, + DbEnrichedChartConfig, + DbEnrichedVariable, } from "@ourworldindata/types" -import { knexRaw } from "../db.js" +import { knexRaw, knexRawFirst } from "../db.js" +import { + updateExistingConfigPair, + updateExistingFullConfig, + updateExistingPatchConfig, +} from "./ChartConfigs.js" + +interface ChartConfigPair { + configId: DbEnrichedChartConfig["id"] + patchConfig: DbEnrichedChartConfig["patch"] + fullConfig: DbEnrichedChartConfig["full"] +} -//export type Field = keyof VariableRow +interface VariableWithGrapherConfigs { + variableId: DbEnrichedVariable["id"] + admin?: ChartConfigPair + etl?: ChartConfigPair +} -export async function getMergedGrapherConfigForVariable( - variableId: number, - knex: db.KnexReadonlyTransaction -): Promise { - const rows: Pick< - DbRawVariable, - "grapherConfigAdmin" | "grapherConfigETL" - >[] = await knexRaw( +export async function getGrapherConfigsForVariable( + knex: db.KnexReadonlyTransaction, + variableId: number +): Promise { + const variable = await knexRawFirst< + Pick< + DbRawVariable, + "id" | "grapherConfigIdAdmin" | "grapherConfigIdETL" + > & { + patchConfigAdmin?: DbRawChartConfig["patch"] + patchConfigETL?: DbRawChartConfig["patch"] + fullConfigAdmin?: DbRawChartConfig["full"] + fullConfigETL?: DbRawChartConfig["full"] + } + >( knex, - `SELECT grapherConfigAdmin, grapherConfigETL FROM variables WHERE id = ?`, + `-- sql + SELECT + v.id, + v.grapherConfigIdAdmin, + v.grapherConfigIdETL, + cc_admin.patch AS patchConfigAdmin, + cc_admin.full AS fullConfigAdmin, + cc_etl.patch AS patchConfigETL, + cc_etl.full AS fullConfigETL + FROM variables v + LEFT JOIN chart_configs cc_admin ON v.grapherConfigIdAdmin = cc_admin.id + LEFT JOIN chart_configs cc_etl ON v.grapherConfigIdETL = cc_etl.id + WHERE v.id = ? + `, [variableId] ) - if (!rows.length) return - const row = rows[0] - const grapherConfigAdmin = row.grapherConfigAdmin - ? JSON.parse(row.grapherConfigAdmin) + + if (!variable) return + + const maybeParseChartConfig = (config: string | undefined) => + config ? parseChartConfig(config) : {} + + const admin = variable.grapherConfigIdAdmin + ? { + configId: variable.grapherConfigIdAdmin, + patchConfig: maybeParseChartConfig(variable.patchConfigAdmin), + fullConfig: maybeParseChartConfig(variable.fullConfigAdmin), + } : undefined - const grapherConfigETL = row.grapherConfigETL - ? JSON.parse(row.grapherConfigETL) + + const etl = variable.grapherConfigIdETL + ? { + configId: variable.grapherConfigIdETL, + patchConfig: maybeParseChartConfig(variable.patchConfigETL), + fullConfig: maybeParseChartConfig(variable.fullConfigETL), + } : undefined - // TODO(inheritance): use mergeGrapherConfigs instead - return _.merge({}, grapherConfigAdmin, grapherConfigETL) + + return omitUndefinedValues({ + variableId: variable.id, + admin, + etl, + }) +} + +export async function getMergedGrapherConfigForVariable( + knex: db.KnexReadonlyTransaction, + variableId: number +): Promise { + const variable = await getGrapherConfigsForVariable(knex, variableId) + return variable?.admin?.fullConfig ?? variable?.etl?.fullConfig +} + +export async function insertNewGrapherConfigForVariable( + knex: db.KnexReadonlyTransaction, + { + type, + variableId, + patchConfig, + fullConfig, + }: { + type: "admin" | "etl" + variableId: number + patchConfig: GrapherInterface + fullConfig: GrapherInterface + } +): Promise { + // insert chart config into the database + const configId = await db.getBinaryUUID(knex) + await db.knexRaw( + knex, + `-- sql + INSERT INTO chart_configs (id, patch, full) + VALUES (?, ?, ?) + `, + [configId, JSON.stringify(patchConfig), JSON.stringify(fullConfig)] + ) + + // make a reference to the config from the variables table + const column = + type === "admin" ? "grapherConfigIdAdmin" : "grapherConfigIdETL" + await db.knexRaw( + knex, + `-- sql + UPDATE variables + SET ?? = ? + WHERE id = ? + `, + [column, configId, variableId] + ) +} + +function makeConfigValidForIndicator({ + config, + variableId, +}: { + config: GrapherInterface + variableId: number +}): GrapherInterface { + const updatedConfig = { ...config } + + // if no schema is given, assume it's the latest + if (!updatedConfig.$schema) { + updatedConfig.$schema = DEFAULT_GRAPHER_CONFIG_SCHEMA + } + + // check if the given dimensions are correct + if (updatedConfig.dimensions && updatedConfig.dimensions.length >= 1) { + // make sure there is only a single entry + updatedConfig.dimensions = updatedConfig.dimensions.slice(0, 1) + // make sure the variable id matches + updatedConfig.dimensions[0].variableId = variableId + } + + // fill dimensions if not given to make the updatedConfig plottable + if (!updatedConfig.dimensions || updatedConfig.dimensions.length === 0) { + updatedConfig.dimensions = [ + { property: DimensionProperty.y, variableId }, + ] + } + + return updatedConfig +} + +async function findAllChartsThatInheritFromIndicator( + trx: db.KnexReadonlyTransaction, + variableId: number +): Promise<{ chartId: number; patchConfig: GrapherInterface }[]> { + const charts = await db.knexRaw<{ + chartId: number + patchConfig: string + }>( + trx, + `-- sql + SELECT c.id as chartId, cc.patch as patchConfig + FROM inheriting_charts ic + JOIN charts c ON c.id = ic.chartId + JOIN chart_configs cc ON cc.id = c.configId + WHERE ic.variableId = ? + `, + [variableId] + ) + return charts.map((chart) => ({ + ...chart, + patchConfig: parseChartConfig(chart.patchConfig), + })) +} + +export async function updateAllChartsThatInheritFromIndicator( + trx: db.KnexReadWriteTransaction, + variableId: number, + { + patchConfigETL, + patchConfigAdmin, + }: { + patchConfigETL?: GrapherInterface + patchConfigAdmin?: GrapherInterface + } +): Promise { + const inheritingCharts = await findAllChartsThatInheritFromIndicator( + trx, + variableId + ) + for (const chart of inheritingCharts) { + const fullConfig = mergeGrapherConfigs( + defaultGrapherConfig, + patchConfigETL ?? {}, + patchConfigAdmin ?? {}, + chart.patchConfig + ) + await db.knexRaw( + trx, + `-- sql + UPDATE chart_configs cc + JOIN charts c ON c.configId = cc.id + SET cc.full = ? + WHERE c.id = ? + `, + [JSON.stringify(fullConfig), chart.chartId] + ) + } +} + +export async function updateGrapherConfigETLOfVariable( + trx: db.KnexReadWriteTransaction, + variable: VariableWithGrapherConfigs, + config: GrapherInterface +): Promise { + const { variableId } = variable + + const configETL = makeConfigValidForIndicator({ + config, + variableId, + }) + + if (variable.etl) { + await updateExistingConfigPair(trx, { + configId: variable.etl.configId, + patchConfig: configETL, + fullConfig: configETL, + }) + } else { + await insertNewGrapherConfigForVariable(trx, { + type: "etl", + variableId, + patchConfig: configETL, + fullConfig: configETL, + }) + } + + // update admin-authored full config it is exists + if (variable.admin) { + const fullConfig = mergeGrapherConfigs( + configETL, + variable.admin.patchConfig + ) + await updateExistingFullConfig(trx, { + configId: variable.admin.configId, + config: fullConfig, + }) + } + + await updateAllChartsThatInheritFromIndicator(trx, variableId, { + patchConfigETL: configETL, + patchConfigAdmin: variable.admin?.patchConfig, + }) +} + +export async function updateGrapherConfigAdminOfVariable( + trx: db.KnexReadWriteTransaction, + variable: VariableWithGrapherConfigs, + config: GrapherInterface +): Promise { + const { variableId } = variable + + const patchConfigAdmin = makeConfigValidForIndicator({ + config, + variableId, + }) + + const fullConfigAdmin = mergeGrapherConfigs( + variable.etl?.patchConfig ?? {}, + patchConfigAdmin + ) + + if (variable.admin) { + await updateExistingConfigPair(trx, { + configId: variable.admin.configId, + patchConfig: patchConfigAdmin, + fullConfig: fullConfigAdmin, + }) + } else { + await insertNewGrapherConfigForVariable(trx, { + type: "admin", + variableId, + patchConfig: patchConfigAdmin, + fullConfig: fullConfigAdmin, + }) + } + + await updateAllChartsThatInheritFromIndicator(trx, variableId, { + patchConfigETL: variable.etl?.patchConfig ?? {}, + patchConfigAdmin: patchConfigAdmin, + }) +} + +export async function getParentConfigForIndicatorChartAdmin( + trx: db.KnexReadonlyTransaction, + variableId: number +): Promise { + // check if there is an ETL-authored indicator chart + const variable = await getGrapherConfigsForVariable(trx, variableId) + return variable?.etl?.fullConfig } // TODO: these are domain functions and should live somewhere else diff --git a/packages/@ourworldindata/grapher/src/index.ts b/packages/@ourworldindata/grapher/src/index.ts index 9e9d08b9902..ed27bd627f5 100644 --- a/packages/@ourworldindata/grapher/src/index.ts +++ b/packages/@ourworldindata/grapher/src/index.ts @@ -26,6 +26,7 @@ export { grapherInterfaceWithHiddenTabsOnly, CONTINENTS_INDICATOR_ID, POPULATION_INDICATOR_ID_USED_IN_ADMIN, + DEFAULT_GRAPHER_CONFIG_SCHEMA, } from "./core/GrapherConstants" export { getVariableDataRoute, diff --git a/packages/@ourworldindata/grapher/src/schema/grapher-schema.004.yaml b/packages/@ourworldindata/grapher/src/schema/grapher-schema.004.yaml index 1d51b63eff6..28e858bd876 100644 --- a/packages/@ourworldindata/grapher/src/schema/grapher-schema.004.yaml +++ b/packages/@ourworldindata/grapher/src/schema/grapher-schema.004.yaml @@ -185,8 +185,7 @@ $defs: description: The minimum bracket of the first bin additionalProperties: false required: - - title - - version + - $schema - dimensions type: object description: | diff --git a/packages/@ourworldindata/types/src/dbTypes/InheritingCharts.ts b/packages/@ourworldindata/types/src/dbTypes/InheritingCharts.ts new file mode 100644 index 00000000000..3a956f68cc7 --- /dev/null +++ b/packages/@ourworldindata/types/src/dbTypes/InheritingCharts.ts @@ -0,0 +1,8 @@ +export const InheritingChartsTableName = "inheriting_charts" + +export interface DbInsertInheritingChart { + variableId: number + chartId: number +} + +export type DbPlainInheritingChart = Required diff --git a/packages/@ourworldindata/types/src/dbTypes/Variables.ts b/packages/@ourworldindata/types/src/dbTypes/Variables.ts index ce241a997c8..3c2d40c5a3f 100644 --- a/packages/@ourworldindata/types/src/dbTypes/Variables.ts +++ b/packages/@ourworldindata/types/src/dbTypes/Variables.ts @@ -1,7 +1,6 @@ import { OwidVariableType } from "../OwidVariable.js" import { OwidVariableDisplayConfigInterface } from "../OwidVariableDisplayConfigInterface.js" import { JsonString } from "../domainTypes/Various.js" -import { GrapherInterface } from "../grapherTypes/GrapherTypes.js" export const VariablesTableName = "variables" export interface DbInsertVariable { @@ -20,8 +19,8 @@ export interface DbInsertVariable { descriptionShort?: string | null dimensions?: JsonString | null display: JsonString - grapherConfigAdmin?: JsonString | null - grapherConfigETL?: JsonString | null + grapherConfigIdAdmin?: Buffer | null + grapherConfigIdETL?: Buffer | null id?: number license?: JsonString | null licenses?: JsonString | null @@ -66,8 +65,6 @@ export type DbEnrichedVariable = Omit< | "dimensions" | "descriptionKey" | "originalMetadata" - | "grapherConfigAdmin" - | "grapherConfigETL" | "processingLog" | "sort" > & { @@ -77,8 +74,6 @@ export type DbEnrichedVariable = Omit< dimensions: VariableDisplayDimension | null descriptionKey: string[] | null originalMetadata: unknown | null - grapherConfigAdmin: GrapherInterface | null - grapherConfigETL: GrapherInterface | null processingLog: unknown | null sort: string[] | null } @@ -149,30 +144,6 @@ export function serializeVariableOriginalMetadata( return originalMetadata ? JSON.stringify(originalMetadata) : null } -export function parseVariableGrapherConfigAdmin( - grapherConfigAdmin: JsonString | null -): GrapherInterface { - return grapherConfigAdmin ? JSON.parse(grapherConfigAdmin) : null -} - -export function serializeVariableGrapherConfigAdmin( - grapherConfigAdmin: GrapherInterface | null -): JsonString | null { - return grapherConfigAdmin ? JSON.stringify(grapherConfigAdmin) : null -} - -export function parseVariableGrapherConfigETL( - grapherConfigETL: JsonString | null -): GrapherInterface { - return grapherConfigETL ? JSON.parse(grapherConfigETL) : null -} - -export function serializeVariableGrapherConfigETL( - grapherConfigETL: GrapherInterface | null -): JsonString | null { - return grapherConfigETL ? JSON.stringify(grapherConfigETL) : null -} - export function parseVariableProcessingLog( processingLog: JsonString | null ): any { @@ -204,10 +175,6 @@ export function parseVariablesRow(row: DbRawVariable): DbEnrichedVariable { dimensions: parseVariableDimensions(row.dimensions), descriptionKey: parseVariableDescriptionKey(row.descriptionKey), originalMetadata: parseVariableOriginalMetadata(row.originalMetadata), - grapherConfigAdmin: parseVariableGrapherConfigAdmin( - row.grapherConfigAdmin - ), - grapherConfigETL: parseVariableGrapherConfigETL(row.grapherConfigETL), processingLog: parseVariableProcessingLog(row.processingLog), sort: parseVariableSort(row.sort), } @@ -224,12 +191,6 @@ export function serializeVariablesRow(row: DbEnrichedVariable): DbRawVariable { originalMetadata: serializeVariableOriginalMetadata( row.originalMetadata ), - grapherConfigAdmin: serializeVariableGrapherConfigAdmin( - row.grapherConfigAdmin - ), - grapherConfigETL: serializeVariableGrapherConfigETL( - row.grapherConfigETL - ), processingLog: serializeVariableProcessingLog(row.processingLog), sort: serializeVariableSort(row.sort), } diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index f888ef464bf..fca5baf4db7 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -634,10 +634,6 @@ export { serializeVariableOriginalMetadata, parseVariableLicenses, serializeVariableLicenses, - parseVariableGrapherConfigAdmin, - serializeVariableGrapherConfigAdmin, - parseVariableGrapherConfigETL, - serializeVariableGrapherConfigETL, parseVariableProcessingLog, serializeVariableProcessingLog, type License, diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index a95acc7721b..28be7ca0086 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -182,6 +182,9 @@ import { TagGraphRoot, TagGraphRootName, TagGraphNode, + GrapherInterface, + ChartTypeName, + DimensionProperty, } from "@ourworldindata/types" import { PointVector } from "./PointVector.js" import React from "react" @@ -1970,3 +1973,20 @@ export function traverseObjects>( } return result } + +export function getParentIndicatorIdFromChartConfig( + config: GrapherInterface +): number | undefined { + const { type, dimensions } = config + + if (type === ChartTypeName.ScatterPlot) return undefined + if (!dimensions) return undefined + + const yVariableIds = dimensions + .filter((d) => d.property === DimensionProperty.y) + .map((d) => d.variableId) + + if (yVariableIds.length !== 1) return undefined + + return yVariableIds[0] +} diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index 7dd328dd516..199920596b3 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -124,6 +124,7 @@ export { createTagGraph, formatInlineList, lazy, + getParentIndicatorIdFromChartConfig, } from "./Util.js" export {