diff --git a/adminSiteClient/AbstractChartEditor.ts b/adminSiteClient/AbstractChartEditor.ts new file mode 100644 index 00000000000..201a3884994 --- /dev/null +++ b/adminSiteClient/AbstractChartEditor.ts @@ -0,0 +1,93 @@ +import { Grapher } from "@ourworldindata/grapher" +import { computed, observable, runInAction, when } from "mobx" +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 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 interface AbstractChartEditorManager { + admin: Admin + grapher: Grapher + database: EditorDatabase +} + +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 } + @observable.ref previewMode: "mobile" | "desktop" + @observable.ref showStaticPreview = false + @observable.ref savedGrapherJson: string = "" + + constructor(props: { manager: Manager }) { + this.manager = props.manager + this.previewMode = + localStorage.getItem("editorPreviewMode") === "mobile" + ? "mobile" + : "desktop" + when( + () => this.grapher.isReady, + () => (this.savedGrapherJson = JSON.stringify(this.grapher.object)) + ) + } + + @computed get isModified(): boolean { + return JSON.stringify(this.grapher.object) !== this.savedGrapherJson + } + + @computed get grapher() { + return this.manager.grapher + } + + @computed get database() { + return this.manager.database + } + + @computed get features() { + return new EditorFeatures(this) + } + + abstract saveGrapher(props: { onError?: () => void }): Promise +} diff --git a/adminSiteClient/AdminApp.tsx b/adminSiteClient/AdminApp.tsx index 56804458a34..d004088460b 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,21 @@ 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 - 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" @@ -93,10 +41,7 @@ export interface DimensionErrorMessage { displayName?: string } -export interface ChartEditorManager { - admin: Admin - grapher: Grapher - database: EditorDatabase +export interface ChartEditorManager extends AbstractChartEditorManager { logs: Log[] references: References | undefined redirects: ChartRedirect[] @@ -111,49 +56,16 @@ export interface ChartEditorManager { > } -interface VariableIdUsageRecord { - variableId: number - 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 savedGrapherJson: string = "" +export class ChartEditor extends AbstractChartEditor { + // TODO: necessary? + constructor(props: { manager: ChartEditorManager }) { + super(props) + } // 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.savedGrapherJson = JSON.stringify(this.grapher.object)) - ) - } - - @computed get isModified(): boolean { - return JSON.stringify(this.grapher.object) !== this.savedGrapherJson - } - - @computed get grapher() { - return this.manager.grapher - } - - @computed get database() { - return this.manager.database - } - @computed get logs() { return this.manager.logs } @@ -193,23 +105,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 { @@ -294,3 +189,11 @@ export class ChartEditor { } } } + +export const getFullReferencesCount = (references: References): number => { + return ( + references.postsWordpress.length + + references.postsGdocs.length + + references.explorers.length + ) +} diff --git a/adminSiteClient/ChartEditorPage.tsx b/adminSiteClient/ChartEditorPage.tsx index ac27d0dd9e6..0a343dc7e23 100644 --- a/adminSiteClient/ChartEditorPage.tsx +++ b/adminSiteClient/ChartEditorPage.tsx @@ -33,15 +33,14 @@ import { Grapher } from "@ourworldindata/grapher" import { Admin } from "./Admin.js" import { ChartEditor, - EditorDatabase, Log, References, ChartEditorManager, - Dataset, getFullReferencesCount, DetailReferences, FieldWithDetailReferences, } from "./ChartEditor.js" +import { Dataset, EditorDatabase } from "./AbstractChartEditor.js" import { EditorBasicTab } from "./EditorBasicTab.js" import { EditorDataTab } from "./EditorDataTab.js" import { EditorTextTab } from "./EditorTextTab.js" @@ -202,6 +201,19 @@ export class ChartEditorPage datasets: groupedByNamespace[namespace] as Dataset[], }) } + + const usageData = (await this.context.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 { diff --git a/adminSiteClient/EditorFeatures.tsx b/adminSiteClient/EditorFeatures.tsx index 068581e61b7..aecb2b810c5 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/IndicatorChartEditor.tsx b/adminSiteClient/IndicatorChartEditor.tsx new file mode 100644 index 00000000000..bfa19fd18de --- /dev/null +++ b/adminSiteClient/IndicatorChartEditor.tsx @@ -0,0 +1,296 @@ +/* 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, +} 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 IndicatorChartEditorManager { + admin: Admin + grapher: Grapher + database: EditorDatabase + logs: Log[] + references: References | undefined + redirects: ChartRedirect[] + pageviews?: RawPageview + allTopics: Topic[] + details: DetailDictionary + invalidDetailReferences: DetailReferences + errorMessages: Partial> + errorMessagesForDimensions: Record< + DimensionProperty, + DimensionErrorMessage[] + > +} + +interface VariableIdUsageRecord { + variableId: number + usageCount: number +} + +export class IndicatorChartEditor { + manager: IndicatorChartEditorManager + // 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 savedGrapherJson: string = "" + + // 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: IndicatorChartEditorManager }) { + this.manager = props.manager + this.previewMode = + localStorage.getItem("editorPreviewMode") === "mobile" + ? "mobile" + : "desktop" + when( + () => this.grapher.isReady, + () => (this.savedGrapherJson = JSON.stringify(this.grapher.object)) + ) + } + + @computed get isModified(): boolean { + return JSON.stringify(this.grapher.object) !== this.savedGrapherJson + } + + @computed get grapher() { + return this.manager.grapher + } + + @computed get database() { + return this.manager.database + } + + @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 allTopics() { + 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") + 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) + } + + 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 { + 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.savedGrapherJson = JSON.stringify(this.grapher.object) + } else { + runInAction(() => { + grapher.version += 1 + this.logs.unshift(json.newLog) + this.savedGrapherJson = JSON.stringify(currentGrapherObject) + }) + } + } 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/IndicatorChartEditorPage.tsx b/adminSiteClient/IndicatorChartEditorPage.tsx new file mode 100644 index 00000000000..081f5ca27aa --- /dev/null +++ b/adminSiteClient/IndicatorChartEditorPage.tsx @@ -0,0 +1,529 @@ +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, + diffGrapherConfigs, +} from "@ourworldindata/utils" +import { + Topic, + GrapherInterface, + GrapherStaticFormat, + ChartRedirect, + DimensionProperty, +} from "@ourworldindata/types" +import { 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() + + 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 + } + } +} + +@observer +// implements IndicatorChartEditorManager +export class IndicatorChartEditorPage extends React.Component<{ + variableId: number +}> { + @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 + baseGrapherConfig?: GrapherInterface + + async fetchGrapher(): Promise { + const { variableId } = this.props + const variable = await this.context.admin.getJSON( + `/api/variables/${variableId}/grapherConfigs` + ) + this.fetchedGrapherConfig = variable.grapherConfigAdmin + this.baseGrapherConfig = variable.grapherConfigETL + + this.updateGrapher() + } + + @observable private _isDbSet = false + @observable private _isGrapherSet = false + @computed get isReady(): boolean { + return this._isDbSet && this._isGrapherSet + } + + @action.bound private updateGrapher(): void { + let config = this.fetchedGrapherConfig + + // if there is a base layer, update the patch instead of the full config + if (config && this.baseGrapherConfig) { + config = diffGrapherConfigs(config, this.baseGrapherConfig) + } + + 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[], + }) + } + } + + 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 + + return new ChartEditor({ manager: this }) + } + + @action.bound refresh(): void { + void this.fetchGrapher() + void this.fetchDetails() + void this.fetchData() + } + + 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() + } + ) + ) + } + + // 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: 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 */} + +
+
+ ) + } +} diff --git a/adminSiteServer/apiRouter.ts b/adminSiteServer/apiRouter.ts index 7d11c1b11e4..b3521c57821 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -26,6 +26,9 @@ import { fetchS3MetadataByPath, fetchS3DataValuesByPath, searchVariables, + getGrapherConfigsForVariable, + updateExistingGrapherConfigForVariable, + insertNewGrapherConfigForVariable, } from "../db/model/Variable.js" import { getCanonicalUrl } from "@ourworldindata/components" import { @@ -91,6 +94,7 @@ import { defaultGrapherConfig, getVariableDataRoute, getVariableMetadataRoute, + DEFAULT_GRAPHER_CONFIG_SCHEMA, } from "@ourworldindata/grapher" import { getDatasetById, setTagsForDataset } from "../db/model/Dataset.js" import { getUserById, insertUser, updateUser } from "../db/model/User.js" @@ -282,7 +286,7 @@ const saveNewChart = async ( } // compute patch and full configs - const baseConfig = getBaseLayerConfig() + const baseConfig = await getBaseLayerConfigForChart(knex) const patchConfig = diffGrapherConfigs(config, baseConfig) const fullConfig = mergeGrapherConfigs(baseConfig, patchConfig) @@ -344,7 +348,7 @@ const updateExistingChart = async ( } // compute patch and full configs - const baseConfig = getBaseLayerConfig() + const baseConfig = await getBaseLayerConfigForChart(knex, chartId) const patchConfig = diffGrapherConfigs(config, baseConfig) const fullConfig = mergeGrapherConfigs(baseConfig, patchConfig) @@ -561,6 +565,31 @@ getRouteWithROTransaction(apiRouter, "/charts.json", async (req, res, trx) => { return { charts } }) +getRouteWithROTransaction( + apiRouter, + "/indicator-charts.json", + async (req, res, trx) => { + const limit = parseIntOrUndefined(req.query.limit as string) ?? 10000 + const indicatorCharts = await db.knexRaw( + trx, + `-- sql + SELECT + v.id AS variableId, + cc.patch AS config, + cc.createdAt, + cc.updatedAt + FROM variables v + JOIN chart_configs cc ON cc.id = v.grapherConfigIdAdmin + ORDER BY cc.updatedAt + DESC LIMIT ? + `, + [limit] + ) + + return { indicatorCharts } + } +) + getRouteWithROTransaction(apiRouter, "/charts.csv", async (req, res, trx) => { const limit = parseIntOrUndefined(req.query.limit as string) ?? 10000 @@ -623,14 +652,94 @@ getRouteWithROTransaction( async (req, res, trx) => expectChartById(trx, req.params.chartId) ) -function getBaseLayerConfig(): GrapherInterface { +async function getBaseLayerConfigForIndicatorChartAdmin( + trx: db.KnexReadonlyTransaction, + variableId: number +): Promise { + // check if there is an ETL-authored indicator chart + const variable = await db.knexRawFirst<{ config: GrapherInterface }>( + trx, + `-- sql + SELECT cc.full AS config + FROM chart_configs cc + JOIN variables v ON v.grapherConfigIdETL = cc.id + WHERE v.id = ? + `, + [variableId] + ) + return variable?.config ?? {} +} + +async function getBaseLayerConfigForChart( + trx: db.KnexReadonlyTransaction, + chartId?: number +): Promise { + if (chartId === undefined) return defaultGrapherConfig + + // check if the chart inherits settings from an indicator + const baseIndicator = await db.knexRawFirst<{ id: number }>( + trx, + `-- sql + SELECT indicatorId AS id + FROM inheriting_charts + WHERE chartId = ? + `, + [chartId] + ) + + if (!baseIndicator) return defaultGrapherConfig + + // check if there is an admin-authored indicator chart + const variableAdmin = await db.knexRawFirst<{ config: GrapherInterface }>( + trx, + `-- sql + SELECT cc.full AS config + FROM chart_configs cc + JOIN variables v ON v.grapherConfigIdAdmin = cc.id + WHERE v.id = ? + `, + [baseIndicator.id] + ) + if (variableAdmin) + return mergeGrapherConfigs(defaultGrapherConfig, variableAdmin.config) + + // check if there is an ETL-authored indicator chart + const variableETL = await db.knexRawFirst<{ config: GrapherInterface }>( + trx, + `-- sql + SELECT cc.full AS config + FROM chart_configs cc + JOIN variables v ON v.grapherConfigIdETL = cc.id + WHERE v.id = ? + `, + [baseIndicator.id] + ) + if (variableETL) + return mergeGrapherConfigs(defaultGrapherConfig, variableETL.config) + return defaultGrapherConfig } getRouteWithROTransaction( apiRouter, "/charts/:chartId.base.json", - async (req, res, trx) => getBaseLayerConfig() + async (req, res, trx) => { + const chartId = expectInt(req.params.chartId) + return getBaseLayerConfigForChart(trx, chartId) + } +) + +getRouteWithROTransaction( + apiRouter, + "/variables/:variableId/grapherConfigs", + async (req, res, trx) => { + const variableId = expectInt(req.params.variableId) + const variable = await getGrapherConfigsForVariable(trx, variableId) + if (!variable) { + throw new JsonError(`No variable with id ${variableId} found`) + } + return variable + } ) getRouteWithROTransaction( @@ -1131,21 +1240,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) => ({ @@ -1154,11 +1267,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 } } @@ -1172,14 +1288,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)) @@ -1189,11 +1316,58 @@ patchRouteWithRWTransaction( } for (const [variableId, newConfig] of configMap.entries()) { - await db.knexRaw( - trx, - `UPDATE variables SET grapherConfigAdmin = ? where id = ?`, - [JSON.stringify(newConfig), variableId] - ) + let configId = ( + await db.knexRawFirst<{ configId: Buffer }>( + trx, + `-- sql + SELECT cc.id AS configId + FROM chart_configs cc + JOIN variables v ON v.grapherConfigIdAdmin = cc.id + WHERE v.id = ? + `, + [variableId] + ) + )?.configId + if (configId) { + await db.knexRaw( + trx, + `-- sql + UPDATE chart_configs + SET + patch = ?, + full = ? + WHERE id = ? + `, + [ + JSON.stringify(newConfig), + JSON.stringify(newConfig), + configId, + ] // TODO: full is wrong!! + ) + } else { + configId = await db.getBinaryUUID(trx) + await db.knexRaw( + trx, + `-- sql + INSERT INTO chart_configs (id, patch, full) + VALUES (?, ?, ?) + `, + [ + configId, + JSON.stringify(newConfig), + JSON.stringify(newConfig), + ] // TODO: full is wrong + ) + await db.knexRaw( + trx, + `-- sql + UPDATE variables + SET grapherConfigIdAdmin = ? + WHERE id = ? + `, + [configId, variableId] + ) + } } return { success: true } @@ -1257,22 +1431,22 @@ getRouteWithROTransaction( await assignTagsForCharts(trx, charts) const grapherConfig = await getMergedGrapherConfigForVariable( - variableId, - trx - ) - if ( - grapherConfig && - (!grapherConfig.dimensions || grapherConfig.dimensions.length === 0) - ) { - const dimensions: OwidChartDimensionInterface[] = [ - { - variableId: variableId, - property: DimensionProperty.y, - display: variable.display, - }, - ] - grapherConfig.dimensions = dimensions - } + 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 @@ -1289,82 +1463,234 @@ getRouteWithROTransaction( } ) -postRouteWithRWTransaction( +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 +} + +getRouteWithROTransaction( apiRouter, - "/variables/:variableId/grapherConfigETL/new", + "/configs/:uuid.json", async (req, res, trx) => { - const variableId = expectInt(req.params.variableId) - // const user = res.locals.user - const config = req.body + const { uuid } = req.params + const row = await db.knexRawFirst>( + trx, + `-- sql + SELECT uuid, patch + FROM chart_configs cc + WHERE uuid = ? + ` + ) + if (!row) { + throw new JsonError(`No config found for UUID ${uuid}`) + } + + return row + } +) - // if no schema is given, assume it's the latest - if (!config.$schema) { - config.$schema = defaultGrapherConfig.$schema +getRouteWithROTransaction( + apiRouter, + "/configs/:uuid.grapherConfigAdmin.json", + async (req, res, trx) => { + const { uuid } = req.params + const row = await db.knexRawFirst( + trx, + `-- sql + SELECT + v.id as variableId, + cc.uuid as configId, + cc.patch as config + FROM variables v + JOIN chart_configs cc ON v.grapherConfigIdAdmin = cc.id + WHERE uuid = ? + ` + ) + if (!row) { + throw new JsonError(`No config found for UUID ${uuid}`) } - // 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 + return row + } +) + +postRouteWithRWTransaction( + apiRouter, + "/variables/:variableId/grapherConfigETL/update", + async (req, res, trx) => { + const variableId = expectInt(req.params.variableId) + const configETL = makeConfigValidForIndicator({ + config: req.body, + variableId, + }) + + const variable = await getGrapherConfigsForVariable(trx, variableId) + if (!variable) { + throw new JsonError(`Variable with id ${variableId} not found`, 500) } - // fill dimensions if not given to make the config plottable - if (!config.dimensions || config.dimensions.length === 0) { - config.dimensions = [{ property: DimensionProperty.y, variableId }] + if (variable.grapherConfigIdETL) { + await updateExistingGrapherConfigForVariable(trx, { + type: "etl", + variableId, + config: configETL, + configId: variable.grapherConfigIdETL, + }) + } else { + await insertNewGrapherConfigForVariable(trx, { + type: "etl", + variableId, + config: configETL, + }) } - // ETL configs inherit from the default - const patchConfigETL = diffGrapherConfigs(config, defaultGrapherConfig) - const fullConfigETL = mergeGrapherConfigs( - defaultGrapherConfig, - patchConfigETL - ) + // // grab the admin-authored indicator chart and update it if there is one + // if (variable.grapherConfigAdmin) { + // const fullConfigAdmin = mergeGrapherConfigs( + // configETL, + // patchConfigAdmin + // ) + + // // update the admin-authored indicator chart + // await db.knexRaw( + // trx, + // `-- sql + // UPDATE variables + // SET full = ? + // WHERE grapherConfigIdAdmin = ? + // `, + // [JSON.stringify(fullConfigAdmin), variableRowAdmin.configId] + // ) + // } - // insert chart config into the database - const configId = await getBinaryUUID(trx) - await db.knexRaw( + // find all charts that inherit from the indicator + const children = await db.knexRaw<{ + chartId: number + patchConfig: string + }>( trx, `-- sql - INSERT INTO chart_configs (id, patch, full) - VALUES (?, ?, ?) + 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 = ? `, - [ - configId, - JSON.stringify(patchConfigETL), - JSON.stringify(fullConfigETL), - ] + [variableId] ) - // make a reference to the config from the variables table + for (const child of children) { + const patchConfigChild = JSON.parse(child.patchConfig) + const fullConfigChild = mergeGrapherConfigs( + defaultGrapherConfig, + configETL, + variable.grapherConfigAdmin ?? {}, + patchConfigChild + ) + 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(fullConfigChild), child.chartId] + ) + } + } +) + +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) + } + + // remove reference in the variables table await db.knexRaw( trx, `-- sql UPDATE variables - SET grapherConfigIdETL = ? + SET grapherConfigIdETL = NULL WHERE id = ? `, - [configId, variableId] + [variableId] ) - // grab the admin-authored indicator chart if there is one - let patchConfigAdmin: GrapherInterface = {} - const row = await db.knexRawFirst<{ - adminConfig: DbRawChartConfig["full"] // TODO: type - }>( + // delete row in the chart_configs table + await db.knexRaw( trx, `-- sql - SELECT cc.patch as adminConfig - FROM chart_configs cc - JOIN variables v ON cc.id = v.grapherConfigIdAdmin - WHERE v.id = ? + DELETE FROM chart_configs + WHERE id = ? `, - [variableId] - ) - if (row) { - patchConfigAdmin = parseChartConfig(row.adminConfig) - } + [variable.grapherConfigIdETL] + ) + + // // grab the admin-authored indicator chart and update it if there is one + // let patchConfigAdmin: GrapherInterface = {} + // const variableRowAdmin = await db.knexRawFirst<{ + // configId: DbRawChartConfig["id"] + // patchConfig: DbRawChartConfig["patch"] + // }>( + // trx, + // `-- sql + // SELECT + // cc.id as configId, + // cc.patch as patchConfig + // FROM chart_configs cc + // JOIN variables v ON cc.id = v.grapherConfigIdAdmin + // WHERE v.id = ? + // `, + // [variableId] + // ) + // if (variableRowAdmin) { + // patchConfigAdmin = parseChartConfig(variableRowAdmin.patchConfig) + + // // update the admin-authored indicator chart + // await db.knexRaw( + // trx, + // `-- sql + // UPDATE variables + // SET full = ? + // WHERE grapherConfigIdAdmin = ? + // `, + // [variableRowAdmin.patchConfig, variableRowAdmin.configId] + // ) + // } // find all charts that inherit from the indicator const children = await db.knexRaw<{ @@ -1386,8 +1712,7 @@ postRouteWithRWTransaction( const patchConfigChild = JSON.parse(child.patchConfig) const fullConfigChild = mergeGrapherConfigs( defaultGrapherConfig, - patchConfigETL, - patchConfigAdmin, + variable.grapherConfigAdmin ?? {}, patchConfigChild ) await db.knexRaw( @@ -1401,6 +1726,8 @@ postRouteWithRWTransaction( [JSON.stringify(fullConfigChild), child.chartId] ) } + + return { success: true } } ) 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 afe9054586e..d3760b8bb55 100644 --- a/baker/siteRenderers.tsx +++ b/baker/siteRenderers.tsx @@ -48,6 +48,7 @@ import { OwidGdoc, OwidGdocDataInsightInterface, DbRawPost, + mergeGrapherConfigs, } from "@ourworldindata/utils" import { extractFormattingOptions } from "../serverUtils/wordpressUtils.js" import { @@ -751,7 +752,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.full AS grapherConfigETL, + cc_admin.full AS grapherConfigAdmin + FROM variables v + JOIN chart_configs cc_admin ON cc.id=v.grapherConfigIdAdmin + JOIN chart_configs cc_etl ON cc.id=v.grapherConfigIdETL + WHERE v.id IN (?) + `, [requiredVariableIds] ) @@ -779,20 +789,17 @@ export const renderExplorerPage = async ( const partialGrapherConfigs = partialGrapherConfigRows .filter((row) => row.grapherConfigAdmin || row.grapherConfigETL) .map((row) => { - const adminConfig = row.grapherConfigAdmin - ? parseGrapherConfigFromRow({ - id: row.id, - config: row.grapherConfigAdmin as string, - }) - : {} - const etlConfig = row.grapherConfigETL - ? parseGrapherConfigFromRow({ - id: row.id, - config: row.grapherConfigETL as string, - }) - : {} - // TODO(inheritance): use mergeGrapherConfigs instead - return mergePartialGrapherConfigs(etlConfig, adminConfig) + if (row.grapherConfigAdmin) { + return parseGrapherConfigFromRow({ + id: row.id, + config: row.grapherConfigAdmin as string, + }) + } else { + return parseGrapherConfigFromRow({ + id: row.id, + config: row.grapherConfigETL as string, + }) + } }) const wpContent = transformedProgram.wpBlockId diff --git a/db/model/Variable.ts b/db/model/Variable.ts index 8bcd74abe0e..ec8677b7064 100644 --- a/db/model/Variable.ts +++ b/db/model/Variable.ts @@ -1,7 +1,13 @@ import _ from "lodash" import { Writable } from "stream" import * as db from "../db.js" -import { retryPromise, isEmpty } from "@ourworldindata/utils" +import { + retryPromise, + isEmpty, + excludeUndefined, + omitUndefinedValues, + mergeGrapherConfigs, +} from "@ourworldindata/utils" import { getVariableDataRoute, getVariableMetadataRoute, @@ -20,33 +26,144 @@ import { GrapherInterface, DbRawVariable, VariablesTableName, + DbRawChartConfig, + parseChartConfig, + DbEnrichedChartConfig, } from "@ourworldindata/types" -import { knexRaw } from "../db.js" +import { knexRaw, knexRawFirst } from "../db.js" + +export async function getGrapherConfigsForVariable( + knex: db.KnexReadonlyTransaction, + variableId: number +): Promise< + | (Pick< + DbRawVariable, + "id" | "grapherConfigIdAdmin" | "grapherConfigIdETL" + > & { + grapherConfigAdmin?: DbEnrichedChartConfig["patch"] + grapherConfigETL?: DbEnrichedChartConfig["patch"] + }) + | undefined +> { + const variable = await knexRawFirst< + Pick< + DbRawVariable, + "id" | "grapherConfigIdAdmin" | "grapherConfigIdETL" + > & { + grapherConfigAdmin?: DbRawChartConfig["patch"] + grapherConfigETL?: DbRawChartConfig["patch"] + } + >( + knex, + `-- sql + SELECT + v.id, + v.grapherConfigIdAdmin, + v.grapherConfigIdETL, + cc_admin.patch AS grapherConfigAdmin, + cc_etl.patch AS grapherConfigETL + 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 (!variable) return -//export type Field = keyof VariableRow + return omitUndefinedValues({ + ...variable, + grapherConfigAdmin: variable.grapherConfigAdmin + ? parseChartConfig(variable.grapherConfigAdmin) + : undefined, + grapherConfigETL: variable.grapherConfigETL + ? parseChartConfig(variable.grapherConfigETL) + : undefined, + }) +} export async function getMergedGrapherConfigForVariable( - variableId: number, - knex: db.KnexReadonlyTransaction + knex: db.KnexReadonlyTransaction, + variableId: number ): Promise { - const rows: Pick< - DbRawVariable, - "grapherConfigAdmin" | "grapherConfigETL" - >[] = await knexRaw( + const variable = await getGrapherConfigsForVariable(knex, variableId) + if (!variable) return + if (!variable.grapherConfigETL && !variable.grapherConfigAdmin) return + return mergeGrapherConfigs( + variable.grapherConfigETL ?? {}, + variable.grapherConfigAdmin ?? {} + ) +} + +export async function insertNewGrapherConfigForVariable( + knex: db.KnexReadonlyTransaction, + params: { + type: "admin" | "etl" + variableId: number + config: GrapherInterface + } +): Promise { + const { type, variableId, config } = params + + // insert chart config into the database + const configId = await db.getBinaryUUID(knex) + await db.knexRaw( knex, - `SELECT grapherConfigAdmin, grapherConfigETL FROM variables WHERE id = ?`, - [variableId] + `-- sql + INSERT INTO chart_configs (id, patch, full) + VALUES (?, ?, ?) + `, + [configId, JSON.stringify(config), JSON.stringify(config)] + ) + + // 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] + ) +} + +export async function updateExistingGrapherConfigForVariable( + knex: db.KnexReadonlyTransaction, + params: { + type: "admin" | "etl" + variableId: number + config: GrapherInterface + configId: Buffer + } +): Promise { + const { variableId, config, configId, type } = params + + // let configId: Buffer | undefined + // if (!params.configId) { + // const variable = await getGrapherConfigsForVariable(knex, variableId) + // if (!variable) return + // configId = + // type === "admin" + // ? variable.grapherConfigIdAdmin ?? undefined + // : variable.grapherConfigIdETL ?? undefined + // } + // if (!configId) return + + await db.knexRaw( + knex, + `-- sql + UPDATE chart_configs + SET + patch = ?, + full = ? + WHERE id = ? + `, + [JSON.stringify(config), JSON.stringify(config), configId] ) - if (!rows.length) return - const row = rows[0] - const grapherConfigAdmin = row.grapherConfigAdmin - ? JSON.parse(row.grapherConfigAdmin) - : undefined - const grapherConfigETL = row.grapherConfigETL - ? JSON.parse(row.grapherConfigETL) - : undefined - // TODO(inheritance): use mergeGrapherConfigs instead - return _.merge({}, grapherConfigAdmin, grapherConfigETL) } // 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/types/src/dbTypes/Variables.ts b/packages/@ourworldindata/types/src/dbTypes/Variables.ts index ce241a997c8..f085efcbf00 100644 --- a/packages/@ourworldindata/types/src/dbTypes/Variables.ts +++ b/packages/@ourworldindata/types/src/dbTypes/Variables.ts @@ -20,8 +20,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 +66,6 @@ export type DbEnrichedVariable = Omit< | "dimensions" | "descriptionKey" | "originalMetadata" - | "grapherConfigAdmin" - | "grapherConfigETL" | "processingLog" | "sort" > & { @@ -77,8 +75,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 +145,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 +176,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 +192,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,