diff --git a/adminSiteClient/AbstractChartEditor.ts b/adminSiteClient/AbstractChartEditor.ts new file mode 100644 index 00000000000..1909d4cd44e --- /dev/null +++ b/adminSiteClient/AbstractChartEditor.ts @@ -0,0 +1,297 @@ +/* ChartEditor.ts + * ================ + * + * Mobx store that represents the current editor state and governs non-UI-related operations. + * + */ + +import { Grapher } from "@ourworldindata/grapher" +import { + type DetailDictionary, + type RawPageview, + Topic, + PostReference, + ChartRedirect, + DimensionProperty, + Json, + GrapherInterface, + diffGrapherConfigs, + isEqual, + omit, +} from "@ourworldindata/utils" +import { computed, observable, runInAction, when } from "mobx" +import { BAKED_GRAPHER_URL } from "../settings/clientSettings.js" +import { Admin } from "./Admin.js" +import { EditorFeatures } from "./EditorFeatures.js" + +type EditorTab = string + +interface Variable { + id: number + name: string +} + +export interface Dataset { + id: number + name: string + namespace: string + version: string | undefined + variables: Variable[] + isPrivate: boolean + nonRedistributable: boolean +} + +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 class EditorDatabase { + @observable.ref namespaces: Namespace[] + @observable.ref variableUsageCounts: Map = new Map() + @observable dataByNamespace: Map = new Map() + + constructor(json: any) { + this.namespaces = json.namespaces + } +} + +export type FieldWithDetailReferences = + | "subtitle" + | "note" + | "axisLabelX" + | "axisLabelY" + +export type DetailReferences = Record + +export interface DimensionErrorMessage { + displayName?: string +} + +export interface AbstractChartEditorManager { + admin: Admin + grapher: Grapher + baseGrapherConfig: GrapherInterface + + // logs: Log[] + // references: References | undefined + // redirects: ChartRedirect[] + // pageviews?: RawPageview + + // database: EditorDatabase + // allTopics: Topic[] + // details: DetailDictionary + // TODO + // invalidDetailReferences: DetailReferences + // errorMessages: Partial> + // errorMessagesForDimensions: Record< + // DimensionProperty, + // DimensionErrorMessage[] + // > +} + +interface VariableIdUsageRecord { + variableId: number + usageCount: number +} + +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" + @observable.ref errorMessage?: { title: string; content: string } // TODO: used for what? + @observable.ref previewMode: "mobile" | "desktop" + @observable.ref showStaticPreview = false + @observable.ref savedPatchConfig: 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 + + constructor(props: { manager: Manager }) { + this.manager = props.manager + this.previewMode = + localStorage.getItem("editorPreviewMode") === "mobile" + ? "mobile" + : "desktop" + when( + () => this.grapher.isReady, + () => (this.savedPatchConfig = this.patchConfig) + ) + } + + @computed get fullConfig(): GrapherInterface { + return this.grapher.object + } + + @computed get patchConfig(): GrapherInterface { + const { baseGrapherConfig } = this.manager + if (!baseGrapherConfig) return this.fullConfig + return diffGrapherConfigs(this.fullConfig, baseGrapherConfig) + } + + @computed get isModified(): boolean { + return !isEqual( + omit(this.patchConfig, "version"), + omit(this.savedPatchConfig, "version") + ) + } + + @computed get grapher() { + return this.manager.grapher + } + + // @computed get logs() { + // return this.manager.logs + // } + + // @computed get references() { + // return this.manager.references + // } + + // @computed get redirects() { + // return this.manager.redirects + // } + + // @computed get pageviews() { + // return this.manager.pageviews + // } + + // @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("revisions") + // tabs.push("refs") + // tabs.push("export") + // return tabs + // } + + // @computed get isNewGrapher() { + // return this.grapher.id === undefined + // } + + @computed get features() { + return new EditorFeatures(this) + } + + abstract get availableTabs(): EditorTab[] + abstract saveGrapher(props?: { onError?: () => void }): Promise + + // async saveGrapher({ + // onError, + // }: { onError?: () => void } = {}): Promise { + // const { grapher, isNewGrapher } = this + // const currentGrapherObject = this.grapher.object + + // // Chart title and slug may be autocalculated from data, in which case they won't be in props + // // But the server will need to know what we calculated in order to do its job + // if (!currentGrapherObject.title) + // currentGrapherObject.title = grapher.displayTitle + + // if (!currentGrapherObject.slug) + // currentGrapherObject.slug = grapher.displaySlug + + // const targetUrl = isNewGrapher + // ? "/api/charts" + // : `/api/charts/${grapher.id}` + + // const json = await this.manager.admin.requestJSON( + // targetUrl, + // currentGrapherObject, + // isNewGrapher ? "POST" : "PUT" + // ) + + // if (json.success) { + // if (isNewGrapher) { + // this.newChartId = json.chartId + // this.grapher.id = json.chartId + // this.savedPatchConfig = json.savedPatch + // } else { + // runInAction(() => { + // grapher.version += 1 + // this.logs.unshift(json.newLog) + // this.savedPatchConfig = json.savedPatch + // }) + // } + // } else onError?.() + // } + + // async saveAsNewGrapher(): Promise { + // const currentGrapherObject = this.grapher.object + + // const chartJson = { ...currentGrapherObject } + // delete chartJson.id + // delete chartJson.isPublished + + // // Need to open intermediary tab before AJAX to avoid popup blockers + // const w = window.open("/", "_blank") as Window + + // const json = await this.manager.admin.requestJSON( + // "/api/charts", + // chartJson, + // "POST" + // ) + // if (json.success) + // w.location.assign( + // this.manager.admin.url(`charts/${json.chartId}/edit`) + // ) + // } + + // publishGrapher(): void { + // const url = `${BAKED_GRAPHER_URL}/${this.grapher.displaySlug}` + + // if (window.confirm(`Publish chart at ${url}?`)) { + // this.grapher.isPublished = true + // void this.saveGrapher({ + // onError: () => (this.grapher.isPublished = undefined), + // }) + // } + // } + + // unpublishGrapher(): void { + // const message = + // this.references && getFullReferencesCount(this.references) > 0 + // ? "WARNING: This chart might be referenced from public posts, please double check before unpublishing. Try to remove the chart anyway?" + // : "Are you sure you want to unpublish this chart?" + // if (window.confirm(message)) { + // this.grapher.isPublished = undefined + // void this.saveGrapher({ + // onError: () => (this.grapher.isPublished = true), + // }) + // } + // } +} diff --git a/adminSiteClient/AdminApp.tsx b/adminSiteClient/AdminApp.tsx index 56804458a34..b50588857b8 100644 --- a/adminSiteClient/AdminApp.tsx +++ b/adminSiteClient/AdminApp.tsx @@ -154,6 +154,17 @@ export class AdminApp extends React.Component<{ path="/charts" component={ChartIndexPage} /> + {/* TODO(inheritance) */} + {/* + */} > - errorMessagesForDimensions: Record< - DimensionProperty, - DimensionErrorMessage[] - > + // details: DetailDictionary + // TODO + // invalidDetailReferences: DetailReferences + // errorMessages: Partial> + // errorMessagesForDimensions: Record< + // DimensionProperty, + // DimensionErrorMessage[] + // > } interface VariableIdUsageRecord { @@ -121,56 +126,56 @@ interface VariableIdUsageRecord { usageCount: number } -export class ChartEditor { - manager: ChartEditorManager - // Whether the current chart state is saved or not - @observable.ref currentRequest: Promise | undefined - @observable.ref tab: EditorTab = "basic" - @observable.ref errorMessage?: { title: string; content: string } - @observable.ref previewMode: "mobile" | "desktop" - @observable.ref showStaticPreview = false - @observable.ref savedPatchConfig: GrapherInterface = {} +export class ChartEditor extends AbstractChartEditor { + // manager: ChartEditorManager + // // Whether the current chart state is saved or not + // @observable.ref currentRequest: Promise | undefined + // @observable.ref tab: EditorTab = "basic" + // @observable.ref errorMessage?: { title: string; content: string } + // @observable.ref previewMode: "mobile" | "desktop" + // @observable.ref showStaticPreview = false + // @observable.ref savedPatchConfig: 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 - constructor(props: { manager: ChartEditorManager }) { - this.manager = props.manager - this.previewMode = - localStorage.getItem("editorPreviewMode") === "mobile" - ? "mobile" - : "desktop" - when( - () => this.grapher.isReady, - () => (this.savedPatchConfig = this.patchConfig) - ) - } - - @computed get fullConfig(): GrapherInterface { - return this.grapher.object - } - - @computed get patchConfig(): GrapherInterface { - const { baseGrapherConfig } = this.manager - if (!baseGrapherConfig) return this.fullConfig - return diffGrapherConfigs(this.fullConfig, baseGrapherConfig) - } - - @computed get isModified(): boolean { - return !isEqual( - omit(this.patchConfig, "version"), - omit(this.savedPatchConfig, "version") - ) - } - - @computed get grapher() { - return this.manager.grapher - } - - @computed get database() { - return this.manager.database - } + // constructor(props: { manager: ChartEditorManager }) { + // this.manager = props.manager + // this.previewMode = + // localStorage.getItem("editorPreviewMode") === "mobile" + // ? "mobile" + // : "desktop" + // when( + // () => this.grapher.isReady, + // () => (this.savedPatchConfig = this.patchConfig) + // ) + // } + + // @computed get fullConfig(): GrapherInterface { + // return this.grapher.object + // } + + // @computed get patchConfig(): GrapherInterface { + // const { baseGrapherConfig } = this.manager + // if (!baseGrapherConfig) return this.fullConfig + // return diffGrapherConfigs(this.fullConfig, baseGrapherConfig) + // } + + // @computed get isModified(): boolean { + // return !isEqual( + // omit(this.patchConfig, "version"), + // omit(this.savedPatchConfig, "version") + // ) + // } + + // @computed get grapher() { + // return this.manager.grapher + // } + + // @computed get database() { + // return this.manager.database + // } @computed get logs() { return this.manager.logs @@ -192,9 +197,9 @@ export class ChartEditor { return this.manager.allTopics } - @computed get details() { - return this.manager.details - } + // @computed get details() { + // return this.manager.details + // } @computed get availableTabs(): EditorTab[] { const tabs: EditorTab[] = ["basic", "data", "text", "customize"] @@ -211,22 +216,22 @@ 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)) - } + // @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, @@ -312,3 +317,9 @@ export class ChartEditor { } } } + +export function isChartEditorInstance( + editor: AbstractChartEditor +): editor is ChartEditor { + return editor instanceof ChartEditor +} 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..9a7592fcc8b 100644 --- a/adminSiteClient/ChartEditorPage.tsx +++ b/adminSiteClient/ChartEditorPage.tsx @@ -64,128 +64,44 @@ import { 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() - - 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 - } - } -} +import { ChartEditorView, ChartEditorViewManager } from "./ChartEditorView.js" @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.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 details: DetailDictionary = {} - @observable.ref grapherElement?: React.ReactElement + // @observable.ref grapherElement?: React.ReactElement static contextType = AdminAppContext context!: AdminAppContextType - @observable simulateVisionDeficiency?: VisionDeficiency + // @observable simulateVisionDeficiency?: VisionDeficiency - fetchedGrapherConfig?: GrapherInterface + fetchedGrapherConfig: GrapherInterface = {} // for now, every chart's previous config layer is the default layer baseGrapherConfig = defaultGrapherConfig async fetchGrapher(): Promise { - const { grapherId } = this.props + const { grapherId, grapherConfig } = this.props if (grapherId !== undefined) { this.fetchedGrapherConfig = 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.fetchedGrapherConfig = grapherConfig } } @@ -237,136 +153,19 @@ 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 { + // TODO: is this a problem? this._isDbSet && this._isGrapherSet + // if (!this.isReady) return undefined return new ChartEditor({ manager: this }) } @action.bound refresh(): void { void this.fetchGrapher() - void this.fetchDetails() - void this.fetchData() void this.fetchLogs() void this.fetchRefs() void this.fetchRedirects() @@ -381,21 +180,6 @@ export class ChartEditorPage componentDidMount(): void { this.refresh() - - this.disposers.push( - reaction( - () => this.editor && this.editor.previewMode, - () => { - if (this.editor) { - localStorage.setItem( - "editorPreviewMode", - this.editor.previewMode - ) - } - this.updateGrapher() - } - ) - ) } // This funny construction allows the "new chart" link to work by forcing an update @@ -409,175 +193,6 @@ export class ChartEditorPage } 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..4f2bf4ec179 --- /dev/null +++ b/adminSiteClient/ChartEditorView.tsx @@ -0,0 +1,638 @@ +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 { defaultGrapherConfig, Grapher } from "@ourworldindata/grapher" +import { Admin } from "./Admin.js" +import { + ChartEditor, + EditorDatabase, + Log, + References, + ChartEditorManager, + 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 { 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" +import { ChartEditorContext } from "./ChartEditorContext.js" +import { AbstractChartEditor } from "./AbstractChartEditor.js" + +@observer +class TabBinder extends React.Component<{ + editor: Editor +}> { + dispose!: IReactionDisposer + componentDidMount(): void { + //window.addEventListener("hashchange", this.onHashChange) + this.onHashChange() + + 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 + } + } +} + +export interface DimensionErrorMessage { + displayName?: string +} + +// interface ChartEditorContextInterface { +// // invalidDetailReferences?: DetailReferences +// errorMessages: Partial> +// errorMessagesForDimensions: Record< +// DimensionProperty, +// DimensionErrorMessage[] +// > +// } + +export interface ChartEditorViewManager { + admin: Admin + fetchedGrapherConfig: GrapherInterface + grapher: Grapher + editor: Editor +} + +@observer +export class ChartEditorView< + Editor extends AbstractChartEditor, +> extends React.Component<{ + manager: 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 + + @computed private get manager(): ChartEditorViewManager { + return this.props.manager + } + + // fetchedGrapherConfig?: GrapherInterface + // for now, every chart's previous config layer is the default layer + // baseGrapherConfig = defaultGrapherConfig + + // async fetchGrapher(): Promise { + // const { grapherId } = this.props + // if (grapherId !== undefined) { + // this.fetchedGrapherConfig = await this.context.admin.getJSON( + // `/api/charts/${grapherId}.config.json` + // ) + // } + // this.updateGrapher() + // } + + // TODO: maybe? + @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.fetchedGrapherConfig + 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 fetchLogs(): Promise { + // const { grapherId } = this.props + // const { admin } = this.context + // const json = + // grapherId === undefined + // ? {} + // : await admin.getJSON(`/api/charts/${grapherId}.logs.json`) + // runInAction(() => (this.logs = json.logs)) + // } + + // async fetchRefs(): Promise { + // const { grapherId } = this.props + // const { admin } = this.context + // const json = + // grapherId === undefined + // ? {} + // : await admin.getJSON( + // `/api/charts/${grapherId}.references.json` + // ) + // runInAction(() => (this.references = json.references)) + // } + + // async fetchRedirects(): Promise { + // const { grapherId } = this.props + // const { admin } = this.context + // const json = + // grapherId === undefined + // ? {} + // : await admin.getJSON(`/api/charts/${grapherId}.redirects.json`) + // runInAction(() => (this.redirects = json.redirects)) + // } + + // async fetchPageviews(): Promise { + // const { grapherId } = this.props + // const { admin } = this.context + // const json = + // grapherId === undefined + // ? {} + // : await admin.getJSON(`/api/charts/${grapherId}.pageviews.json`) + // runInAction(() => (this.pageviews = json.pageviews)) + // } + + // async fetchTopics(): Promise { + // const { admin } = this.context + // const json = await admin.getJSON(`/api/topics.json`) + // 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.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() + + // (2024-02-15) Disabled due to slow query performance + // https://github.com/owid/owid-grapher/issues/3198 + // this.fetchTopics() + } + + disposers: IReactionDisposer[] = [] + + 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: needed? + // 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()) + } + + 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 + + return ( + + {/* TODO */} + {isChartEditorInstance(editor) && !editor.newChartId && ( + + )} + {isChartEditorInstance(editor) && editor.newChartId && ( + + )} + +
+ +
+ {editor.tab === "basic" && ( + + )} + {editor.tab === "text" && ( + + )} + {editor.tab === "data" && ( + + )} + {editor.tab === "customize" && ( + + )} + {editor.tab === "scatter" && ( + + )} + {editor.tab === "marimekko" && ( + + )} + {editor.tab === "map" && ( + + )} + {isChartEditorInstance(editor) && + editor.tab === "revisions" && ( + + )} + {isChartEditorInstance(editor) && + 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 */} + +
+
+ ) + } +} diff --git a/adminSiteClient/DimensionCard.tsx b/adminSiteClient/DimensionCard.tsx index cbb6ab2d9a0..0271fe09d6c 100644 --- a/adminSiteClient/DimensionCard.tsx +++ b/adminSiteClient/DimensionCard.tsx @@ -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 "./AbstractChartEditor.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..d64ac148036 100644 --- a/adminSiteClient/EditorBasicTab.tsx +++ b/adminSiteClient/EditorBasicTab.tsx @@ -38,12 +38,19 @@ import { Draggable, DropResult, } from "react-beautiful-dnd" +import { ChartEditorContext } from "./ChartEditorContext.js" +import { AbstractChartEditor, EditorDatabase } from "./AbstractChartEditor.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 +60,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 +274,7 @@ class DimensionSlotView extends React.Component<{ {isSelectingVariables && ( (this.isSelectingVariables = false) @@ -280,7 +288,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 +306,7 @@ class VariablesSection extends React.Component<{ editor: ChartEditor }> { key={slot.name} slot={slot} editor={props.editor} + database={props.database} /> ))} @@ -305,7 +316,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 +384,10 @@ export class EditorBasicTab extends React.Component<{ editor: ChartEditor }> { /> - + ) } diff --git a/adminSiteClient/EditorCustomizeTab.tsx b/adminSiteClient/EditorCustomizeTab.tsx index 7cc973c0820..7c81aa1bbd1 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 "./AbstractChartEditor.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..44ea8ea6d1d 100644 --- a/adminSiteClient/EditorDataTab.tsx +++ b/adminSiteClient/EditorDataTab.tsx @@ -18,6 +18,7 @@ import { Droppable, DropResult, } from "react-beautiful-dnd" +import { AbstractChartEditor } from "./AbstractChartEditor.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..2dd19af3f14 100644 --- a/adminSiteClient/EditorExportTab.tsx +++ b/adminSiteClient/EditorExportTab.tsx @@ -8,6 +8,7 @@ import { triggerDownloadFromBlob, GrapherStaticFormat, } from "@ourworldindata/utils" +import { AbstractChartEditor } from "./AbstractChartEditor.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..0de93aa88d0 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 "./AbstractChartEditor.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..569c15bc134 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 "./AbstractChartEditor.js" function LogCompareModal({ log, diff --git a/adminSiteClient/EditorMapTab.tsx b/adminSiteClient/EditorMapTab.tsx index 25e4594fee8..dec6b71e4d4 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 "./AbstractChartEditor.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..ef02ee79413 100644 --- a/adminSiteClient/EditorReferencesTab.tsx +++ b/adminSiteClient/EditorReferencesTab.tsx @@ -13,6 +13,7 @@ import { formatValue, ChartRedirect, } from "@ourworldindata/utils" +import { AbstractChartEditor } from "./AbstractChartEditor.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..dc3ab4d5a21 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 "./AbstractChartEditor.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/SaveButtons.tsx b/adminSiteClient/SaveButtons.tsx index 1ed6ecc5c0c..7ee3b335542 100644 --- a/adminSiteClient/SaveButtons.tsx +++ b/adminSiteClient/SaveButtons.tsx @@ -1,28 +1,35 @@ 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 "./AbstractChartEditor.js" @observer -export class SaveButtons extends React.Component<{ editor: ChartEditor }> { +export class SaveButtons< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor }> { + // TODO + static contextType = ChartEditorContext + @action.bound onSaveChart() { void this.props.editor.saveGrapher() } - @action.bound onSaveAsNew() { - void this.props.editor.saveAsNewGrapher() + // TODO + + @action.bound onSaveAsNew(editor: ChartEditor) { + void editor.saveAsNewGrapher() } - @action.bound onPublishToggle() { - if (this.props.editor.grapher.isPublished) - this.props.editor.unpublishGrapher() - else this.props.editor.publishGrapher() + @action.bound onPublishToggle(editor: ChartEditor) { + if (editor.grapher.isPublished) editor.unpublishGrapher() + else editor.publishGrapher() } @computed get hasEditingErrors(): boolean { - const { editor } = this.props - const { errorMessages, errorMessagesForDimensions } = editor.manager + const { errorMessages, errorMessagesForDimensions } = this.context if (!isEmpty(errorMessages)) return true @@ -52,30 +59,25 @@ export class SaveButtons extends React.Component<{ editor: ChartEditor }> { ? "Save draft" : "Create draft"} {" "} - {" "} - + {isChartEditorInstance(editor) && ( + <> + + + + )}
) - - /*return
- - {" "} - {" "} -
*/ } } diff --git a/adminSiteClient/VariableSelector.tsx b/adminSiteClient/VariableSelector.tsx index b146dd28e15..9d1579637a6 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 "./AbstractChartEditor.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..974b1333bc7 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,12 @@ import { fetchS3MetadataByPath, fetchS3DataValuesByPath, searchVariables, + getGrapherConfigsForVariable, + updateGrapherConfigAdminOfVariable, + updateGrapherConfigETLOfVariable, + updateAllChartsThatInheritFromIndicator, } from "../db/model/Variable.js" +import { updateExistingFullConfig } from "../db/model/ChartConfigs.js" import { getCanonicalUrl } from "@ourworldindata/components" import { camelCaseProperties, @@ -37,8 +44,6 @@ import { parseIntOrUndefined, DbRawPostWithGdocPublishStatus, OwidVariableWithSource, - OwidChartDimensionInterface, - DimensionProperty, TaggableType, DbChartTagJoin, pick, @@ -281,7 +286,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 +348,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) @@ -625,6 +630,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 +1147,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 +1174,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 +1195,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 +1223,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 } @@ -1259,22 +1289,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 +1308,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..ba18080240d --- /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) + VALUES (?, ?) + `, + [configId, 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..78c95b1f82d 100644 --- a/db/model/Chart.ts +++ b/db/model/Chart.ts @@ -1,11 +1,15 @@ 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, } from "@ourworldindata/utils" import { GrapherInterface, @@ -18,7 +22,9 @@ import { DbPlainTag, DbRawChartConfig, DbEnrichedChartConfig, + DimensionProperty, } from "@ourworldindata/types" +import { defaultGrapherConfig } from "@ourworldindata/grapher" import { OpenAI } from "openai" import { BAKED_BASE_URL, @@ -232,6 +238,56 @@ 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 indicatorId 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 { type, dimensions } = config + + if (type === ChartTypeName.ScatterPlot) return defaultGrapherConfig + if (!dimensions) return defaultGrapherConfig + + const yVariableIds = dimensions + .filter((d) => d.property === DimensionProperty.y) + .map((d) => d.variableId) + + if (yVariableIds.length !== 1) return defaultGrapherConfig + + const parentVariableId = yVariableIds[0] + 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..091a6434925 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, + }) +} + +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,