diff --git a/adminSiteClient/AbstractChartEditor.ts b/adminSiteClient/AbstractChartEditor.ts new file mode 100644 index 00000000000..0fda11075f3 --- /dev/null +++ b/adminSiteClient/AbstractChartEditor.ts @@ -0,0 +1,148 @@ +import { + isEqual, + omit, + GrapherInterface, + diffGrapherConfigs, + mergeGrapherConfigs, +} from "@ourworldindata/utils" +import { action, computed, observable, when } from "mobx" +import { EditorFeatures } from "./EditorFeatures.js" +import { Admin } from "./Admin.js" +import { defaultGrapherConfig, Grapher } from "@ourworldindata/grapher" + +export type EditorTab = + | "basic" + | "data" + | "text" + | "customize" + | "map" + | "scatter" + | "marimekko" + | "revisions" + | "refs" + | "export" + | "inheritance" + | "debug" + +export interface AbstractChartEditorManager { + admin: Admin + patchConfig: GrapherInterface + parentConfig?: GrapherInterface + isInheritanceEnabled?: boolean +} + +export abstract class AbstractChartEditor< + Manager extends AbstractChartEditorManager = AbstractChartEditorManager, +> { + manager: Manager + + @observable.ref grapher = new Grapher() + @observable.ref currentRequest: Promise | undefined // Whether the current chart state is saved or not + @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 = {} + + // parent config derived from the current chart config + @observable.ref parentConfig: GrapherInterface | undefined = undefined + // if inheritance is enabled, the parent config is applied to grapher + @observable.ref isInheritanceEnabled: boolean | undefined = undefined + + constructor(props: { manager: Manager }) { + this.manager = props.manager + this.previewMode = + localStorage.getItem("editorPreviewMode") === "mobile" + ? "mobile" + : "desktop" + + when( + () => this.manager.parentConfig !== undefined, + () => (this.parentConfig = this.manager.parentConfig) + ) + + when( + () => this.manager.isInheritanceEnabled !== undefined, + () => + (this.isInheritanceEnabled = this.manager.isInheritanceEnabled) + ) + + when( + () => this.grapher.hasData && this.grapher.isReady, + () => (this.savedPatchConfig = this.patchConfig) + ) + } + + /** original grapher config used to init the grapher instance */ + @computed get originalGrapherConfig(): GrapherInterface { + const { patchConfig, parentConfig, isInheritanceEnabled } = this.manager + if (!isInheritanceEnabled) return patchConfig + return mergeGrapherConfigs(parentConfig ?? {}, patchConfig) + } + + /** live-updating full config */ + @computed get fullConfig(): GrapherInterface { + return mergeGrapherConfigs(defaultGrapherConfig, this.grapher.object) + } + + /** parent config currently applied to grapher */ + @computed get activeParentConfig(): GrapherInterface | undefined { + return this.isInheritanceEnabled ? this.parentConfig : undefined + } + + @computed get activeParentConfigWithDefaults(): + | GrapherInterface + | undefined { + if (!this.activeParentConfig) return undefined + return mergeGrapherConfigs( + defaultGrapherConfig, + this.activeParentConfig + ) + } + + /** patch config of the chart that is written to the db on save */ + @computed get patchConfig(): GrapherInterface { + return diffGrapherConfigs( + this.fullConfig, + this.activeParentConfigWithDefaults ?? defaultGrapherConfig + ) + } + + @computed get isModified(): boolean { + return !isEqual( + omit(this.patchConfig, "version"), + omit(this.savedPatchConfig, "version") + ) + } + + @computed get features(): EditorFeatures { + return new EditorFeatures(this) + } + + @action.bound updateLiveGrapher(config: GrapherInterface): void { + this.grapher.reset() + this.grapher.updateFromObject(config) + this.grapher.updateAuthoredVersion(config) + } + + // only works for top-level properties + isPropertyInherited(property: keyof GrapherInterface): boolean { + if (!this.isInheritanceEnabled || !this.activeParentConfigWithDefaults) + return false + return ( + !Object.hasOwn(this.patchConfig, property) && + Object.hasOwn(this.activeParentConfigWithDefaults, property) + ) + } + + // only works for top-level properties + couldPropertyBeInherited(property: keyof GrapherInterface): boolean { + if (!this.isInheritanceEnabled || !this.activeParentConfig) return false + return Object.hasOwn(this.activeParentConfig, property) + } + + abstract get isNewGrapher(): boolean + abstract get availableTabs(): EditorTab[] + + abstract saveGrapher(props?: { onError?: () => void }): Promise +} diff --git a/adminSiteClient/AdminApp.tsx b/adminSiteClient/AdminApp.tsx index 56804458a34..0642aa18eb7 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 }> { @@ -200,6 +201,17 @@ export class AdminApp extends React.Component<{ path="/users" component={UsersIndexPage} /> + ( + + )} + /> { ) } -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 ChartEditorManager { - admin: Admin - grapher: Grapher - database: EditorDatabase +export interface ChartEditorManager extends AbstractChartEditorManager { 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 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 { // 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 } @@ -170,12 +73,9 @@ export class ChartEditor { return this.manager.pageviews } - @computed get allTopics() { - return this.manager.allTopics - } - - @computed get details() { - return this.manager.details + /** parent variable id, derived from the config */ + @computed get parentVariableId(): number | undefined { + return getParentVariableIdFromChartConfig(this.fullConfig) } @computed get availableTabs(): EditorTab[] { @@ -186,6 +86,7 @@ export class ChartEditor { tabs.push("revisions") tabs.push("refs") tabs.push("export") + tabs.push("debug") return tabs } @@ -193,44 +94,62 @@ 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, - ]) + @action.bound async updateParentConfig() { + const currentParentIndicatorId = + this.parentConfig?.dimensions?.[0].variableId + const newParentIndicatorId = getParentVariableIdFromChartConfig( + this.grapher.object ) - runInAction(() => (this.database.variableUsageCounts = finalData)) + + // no-op if the parent indicator hasn't changed + if (currentParentIndicatorId === newParentIndicatorId) return + + // fetch the new parent config + let newParentConfig: GrapherInterface | undefined + if (newParentIndicatorId) { + newParentConfig = await fetchMergedGrapherConfigByVariableId( + this.manager.admin, + newParentIndicatorId + ) + } + + // if inheritance is enabled, update the live grapher object + if (this.isInheritanceEnabled) { + const newConfig = mergeGrapherConfigs( + newParentConfig ?? {}, + this.patchConfig + ) + this.updateLiveGrapher(newConfig) + } + + // update the parent config in any case + this.parentConfig = newParentConfig } async saveGrapher({ onError, }: { onError?: () => void } = {}): Promise { - const { grapher, isNewGrapher } = this - const currentGrapherObject = this.grapher.object + const { grapher, isNewGrapher, patchConfig } = this // 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 (!patchConfig.title) patchConfig.title = grapher.displayTitle + if (!patchConfig.slug) patchConfig.slug = grapher.displaySlug - if (!currentGrapherObject.slug) - currentGrapherObject.slug = grapher.displaySlug + // it only makes sense to enable inheritance if the chart has a parent + const shouldEnableInheritance = + !!this.parentVariableId && this.isInheritanceEnabled + const query = new URLSearchParams({ + inheritance: shouldEnableInheritance ? "enable" : "disable", + }) const targetUrl = isNewGrapher - ? "/api/charts" - : `/api/charts/${grapher.id}` + ? `/api/charts?${query}` + : `/api/charts/${grapher.id}?${query}` const json = await this.manager.admin.requestJSON( targetUrl, - currentGrapherObject, + patchConfig, isNewGrapher ? "POST" : "PUT" ) @@ -238,29 +157,40 @@ export class ChartEditor { if (isNewGrapher) { this.newChartId = json.chartId this.grapher.id = json.chartId - this.savedGrapherJson = JSON.stringify(this.grapher.object) + this.savedPatchConfig = json.savedPatch + this.isInheritanceEnabled = shouldEnableInheritance } else { runInAction(() => { grapher.version += 1 this.logs.unshift(json.newLog) - this.savedGrapherJson = JSON.stringify(currentGrapherObject) + this.savedPatchConfig = json.savedPatch + this.isInheritanceEnabled = shouldEnableInheritance }) } } else onError?.() } async saveAsNewGrapher(): Promise { - const currentGrapherObject = this.grapher.object + const { patchConfig } = this - const chartJson = { ...currentGrapherObject } + const chartJson = { ...patchConfig } delete chartJson.id delete chartJson.isPublished // Need to open intermediary tab before AJAX to avoid popup blockers const w = window.open("/", "_blank") as Window + // it only makes sense to enable inheritance if the chart has a parent + const shouldEnableInheritance = + !!this.parentVariableId && this.isInheritanceEnabled + + const query = new URLSearchParams({ + inheritance: shouldEnableInheritance ? "enable" : "disable", + }) + const targetUrl = `/api/charts?${query}` + const json = await this.manager.admin.requestJSON( - "/api/charts", + targetUrl, chartJson, "POST" ) @@ -294,3 +224,19 @@ export class ChartEditor { } } } + +export async function fetchMergedGrapherConfigByVariableId( + admin: Admin, + indicatorId: number +): Promise { + const indicatorChart = await admin.getJSON( + `/api/variables/mergedGrapherConfig/${indicatorId}.json` + ) + return isEmpty(indicatorChart) ? undefined : indicatorChart +} + +export function isChartEditorInstance( + editor: AbstractChartEditor +): editor is ChartEditor { + return editor instanceof ChartEditor +} diff --git a/adminSiteClient/ChartEditorPage.tsx b/adminSiteClient/ChartEditorPage.tsx index e1d1a49fc82..b0c1baa3331 100644 --- a/adminSiteClient/ChartEditorPage.tsx +++ b/adminSiteClient/ChartEditorPage.tsx @@ -1,188 +1,74 @@ import React from "react" import { observer } from "mobx-react" +import { observable, computed, runInAction, action } from "mobx" import { - observable, - computed, - runInAction, - autorun, - action, - reaction, - IReactionDisposer, -} from "mobx" -import { Prompt, Redirect } from "react-router-dom" -import { - Bounds, - capitalize, + getParentVariableIdFromChartConfig, RawPageview, - DetailDictionary, - get, - set, - groupBy, - extractDetailsFromSyntax, - getIndexableKeys, } from "@ourworldindata/utils" -import { - Topic, - GrapherInterface, - GrapherStaticFormat, - ChartRedirect, - DimensionProperty, -} from "@ourworldindata/types" -import { Grapher } from "@ourworldindata/grapher" +import { GrapherInterface, ChartRedirect } from "@ourworldindata/types" import { Admin } from "./Admin.js" import { ChartEditor, - EditorDatabase, Log, References, ChartEditorManager, - Dataset, - getFullReferencesCount, - DetailReferences, - FieldWithDetailReferences, + fetchMergedGrapherConfigByVariableId, } 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 - } - } -} +import { ChartEditorView, ChartEditorViewManager } from "./ChartEditorView.js" @observer export class ChartEditorPage extends React.Component<{ grapherId?: number - newGrapherIndex?: number - grapherConfig?: any + grapherConfig?: GrapherInterface }> - implements ChartEditorManager + implements ChartEditorManager, ChartEditorViewManager { - @observable.ref grapher = new Grapher() - @observable.ref database = new EditorDatabase({}) + static contextType = AdminAppContext + context!: AdminAppContextType + @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 + patchConfig: GrapherInterface = {} + parentConfig: GrapherInterface | undefined = undefined - @observable simulateVisionDeficiency?: VisionDeficiency + isInheritanceEnabled: boolean | undefined = undefined - fetchedGrapherConfig?: any - - async fetchGrapher(): Promise { - const { grapherId } = this.props + async fetchGrapherConfig(): Promise { + const { grapherId, grapherConfig } = this.props if (grapherId !== undefined) { - this.fetchedGrapherConfig = await this.context.admin.getJSON( - `/api/charts/${grapherId}.config.json` + this.patchConfig = await this.context.admin.getJSON( + `/api/charts/${grapherId}.patchConfig.json` ) + } else if (grapherConfig) { + this.patchConfig = grapherConfig } - 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[], - }) + async fetchParentConfig(): Promise { + const { grapherId, grapherConfig } = this.props + if (grapherId !== undefined) { + const parent = await this.context.admin.getJSON( + `/api/charts/${grapherId}.parent.json` + ) + this.parentConfig = parent?.config + this.isInheritanceEnabled = parent?.isActive ?? true + } else if (grapherConfig) { + const parentIndicatorId = + getParentVariableIdFromChartConfig(grapherConfig) + if (parentIndicatorId) { + this.parentConfig = await fetchMergedGrapherConfigByVariableId( + this.context.admin, + parentIndicatorId + ) + } + this.isInheritanceEnabled = true + } else { + this.isInheritanceEnabled = true } } @@ -228,353 +114,28 @@ export class ChartEditorPage 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.grapher.defaultBounds - } - - @computed private get staticFormat(): GrapherStaticFormat { - return this.isMobilePreview - ? GrapherStaticFormat.square - : GrapherStaticFormat.landscape - } - - // unvalidated terms extracted from the subtitle and note fields - // these may point to non-existent details e.g. ["not_a_real_term", "pvotery"] - @computed - get currentDetailReferences(): DetailReferences { - return { - subtitle: extractDetailsFromSyntax(this.grapher.currentSubtitle), - note: extractDetailsFromSyntax(this.grapher.note), - axisLabelX: extractDetailsFromSyntax( - this.grapher.xAxisConfig.label ?? "" - ), - axisLabelY: extractDetailsFromSyntax( - this.grapher.yAxisConfig.label ?? "" - ), - } - } - - // the actual Detail objects, indexed by category.term - @computed get currentlyReferencedDetails(): GrapherInterface["details"] { - const grapherConfigDetails: GrapherInterface["details"] = {} - const allReferences = Object.values(this.currentDetailReferences).flat() - - allReferences.forEach((term) => { - const detail = get(this.details, term) - if (detail) { - set(grapherConfigDetails, term, detail) - } - }) - - return grapherConfigDetails - } - - @computed - get invalidDetailReferences(): ChartEditorManager["invalidDetailReferences"] { - const { subtitle, note, axisLabelX, axisLabelY } = - this.currentDetailReferences - return { - subtitle: subtitle.filter((term) => !this.details[term]), - note: note.filter((term) => !this.details[term]), - axisLabelX: axisLabelX.filter((term) => !this.details[term]), - axisLabelY: axisLabelY.filter((term) => !this.details[term]), - } - } - - @computed get errorMessages(): ChartEditorManager["errorMessages"] { - const { invalidDetailReferences } = this - - const errorMessages: ChartEditorManager["errorMessages"] = {} - - // add error messages for each field with invalid detail references - getIndexableKeys(invalidDetailReferences).forEach( - (key: FieldWithDetailReferences) => { - const references = invalidDetailReferences[key] - if (references.length) { - errorMessages[key] = - `Invalid detail(s) specified: ${references.join(", ")}` - } - } - ) - - return errorMessages - } - - @computed - get errorMessagesForDimensions(): ChartEditorManager["errorMessagesForDimensions"] { - const errorMessages: ChartEditorManager["errorMessagesForDimensions"] = - { - [DimensionProperty.y]: [], - [DimensionProperty.x]: [], - [DimensionProperty.color]: [], - [DimensionProperty.size]: [], - [DimensionProperty.table]: [], // not used - } - - this.grapher.dimensionSlots.forEach((slot) => { - slot.dimensions.forEach((dimension, dimensionIndex) => { - const details = extractDetailsFromSyntax( - dimension.display.name ?? "" - ) - const hasDetailsInDisplayName = details.length > 0 - - // add error message if details are referenced in the display name - if (hasDetailsInDisplayName) { - errorMessages[slot.property][dimensionIndex] = { - displayName: "Detail syntax is not supported", - } - } - }) - }) - - return errorMessages - } - @computed get admin(): Admin { return this.context.admin } - @computed get editor(): ChartEditor | undefined { - if (!this.isReady) return undefined - + @computed get editor(): ChartEditor { return new ChartEditor({ manager: this }) } @action.bound refresh(): void { - void this.fetchGrapher() - void this.fetchDetails() - void this.fetchData() + void this.fetchGrapherConfig() + void this.fetchParentConfig() void this.fetchLogs() void this.fetchRefs() void this.fetchRedirects() void this.fetchPageviews() - - // (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.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 */} - -
-
- ) + return } } diff --git a/adminSiteClient/ChartEditorTypes.ts b/adminSiteClient/ChartEditorTypes.ts new file mode 100644 index 00000000000..d1d6f7ffd12 --- /dev/null +++ b/adminSiteClient/ChartEditorTypes.ts @@ -0,0 +1,18 @@ +import { DimensionProperty } from "@ourworldindata/types" + +export type FieldWithDetailReferences = + | "subtitle" + | "note" + | "axisLabelX" + | "axisLabelY" + +export interface DimensionErrorMessage { + displayName?: string +} + +export type ErrorMessages = Partial> + +export type ErrorMessagesForDimensions = Record< + DimensionProperty, + DimensionErrorMessage[] +> diff --git a/adminSiteClient/ChartEditorView.tsx b/adminSiteClient/ChartEditorView.tsx new file mode 100644 index 00000000000..0aa6e335213 --- /dev/null +++ b/adminSiteClient/ChartEditorView.tsx @@ -0,0 +1,544 @@ +import React from "react" +import { observer } from "mobx-react" +import { + observable, + computed, + runInAction, + action, + reaction, + IReactionDisposer, +} from "mobx" +import { Prompt, Redirect } from "react-router-dom" +import { + Bounds, + capitalize, + DetailDictionary, + get, + set, + groupBy, + extractDetailsFromSyntax, + getIndexableKeys, +} from "@ourworldindata/utils" +import { + GrapherInterface, + GrapherStaticFormat, + DimensionProperty, +} from "@ourworldindata/types" +import { Grapher } from "@ourworldindata/grapher" +import { Admin } from "./Admin.js" +import { getFullReferencesCount, 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 { EditorInheritanceTab } from "./EditorInheritanceTab.js" +import { EditorDebugTab } from "./EditorDebugTab.js" +import { SaveButtons } from "./SaveButtons.js" +import { LoadingBlocker } from "./Forms.js" +import { AdminLayout } from "./AdminLayout.js" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" +import { faMobile, faDesktop } from "@fortawesome/free-solid-svg-icons" +import { + VisionDeficiency, + VisionDeficiencySvgFilters, + VisionDeficiencyDropdown, + VisionDeficiencyEntity, +} from "./VisionDeficiencies.js" +import { EditorMarimekkoTab } from "./EditorMarimekkoTab.js" +import { EditorExportTab } from "./EditorExportTab.js" +import { runDetailsOnDemand } from "../site/detailsOnDemand.js" +import { AbstractChartEditor } from "./AbstractChartEditor.js" +import { + ErrorMessages, + ErrorMessagesForDimensions, + FieldWithDetailReferences, +} from "./ChartEditorTypes.js" +import { isIndicatorChartEditorInstance } from "./IndicatorChartEditor.js" + +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 type DetailReferences = Record + +export interface ChartEditorViewManager { + admin: Admin + editor: Editor +} + +@observer +export class ChartEditorView< + Editor extends AbstractChartEditor, +> extends React.Component<{ + manager: ChartEditorViewManager +}> { + @observable.ref database = new EditorDatabase({}) + @observable details: DetailDictionary = {} + @observable.ref grapherElement?: React.ReactElement + + @observable simulateVisionDeficiency?: VisionDeficiency + + @computed private get manager(): ChartEditorViewManager { + return this.props.manager + } + + @observable private _isDbSet = false + @observable private _isGrapherSet = false + @computed get isReady(): boolean { + return this._isDbSet && this._isGrapherSet + } + + @action.bound private updateGrapher(): void { + const config = this.manager.editor.originalGrapherConfig + const grapherConfig = { + ...config, + // binds the grapher instance to this.grapher + getGrapherInstance: (grapher: Grapher) => { + this.manager.editor.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.editor.grapher.renderToStatic = + !!this.editor?.showStaticPreview + this.grapherElement = + this._isGrapherSet = true + } + + @action.bound private setDb(json: any): void { + this.database = new EditorDatabase(json) + this._isDbSet = true + } + + async fetchData(): Promise { + const { admin } = this.manager + + const [namespaces, variables] = await Promise.all([ + admin.getJSON(`/api/editorData/namespaces.json`), + admin.getJSON(`/api/editorData/variables.json`), + ]) + + this.setDb(namespaces) + + const groupedByNamespace = groupBy( + variables.datasets, + (d) => d.namespace + ) + for (const namespace in groupedByNamespace) { + this.database.dataByNamespace.set(namespace, { + datasets: groupedByNamespace[namespace] as Dataset[], + }) + } + + const usageData = (await admin.getJSON( + `/api/variables.usages.json` + )) as { + variableId: number + usageCount: number + }[] + this.database.variableUsageCounts = new Map( + usageData.map(({ variableId, usageCount }) => [ + variableId, + +usageCount, + ]) + ) + } + + async fetchDetails(): Promise { + await runDetailsOnDemand() + + runInAction(() => { + if (window.details) this.details = window.details + }) + } + + @computed private get isMobilePreview(): boolean { + return this.editor?.previewMode === "mobile" + } + + @computed private get bounds(): Bounds { + return this.isMobilePreview + ? new Bounds(0, 0, 380, 525) + : this.manager.editor.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.editor + 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(): ErrorMessages { + const { invalidDetailReferences } = this + + const errorMessages: 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(): ErrorMessagesForDimensions { + const errorMessages: ErrorMessagesForDimensions = { + [DimensionProperty.y]: [], + [DimensionProperty.x]: [], + [DimensionProperty.color]: [], + [DimensionProperty.size]: [], + [DimensionProperty.table]: [], // not used + } + + this.manager.editor.grapher.dimensionSlots.forEach((slot) => { + slot.dimensions.forEach((dimension, dimensionIndex) => { + const details = extractDetailsFromSyntax( + dimension.display.name ?? "" + ) + const hasDetailsInDisplayName = details.length > 0 + + // add error message if details are referenced in the display name + if (hasDetailsInDisplayName) { + errorMessages[slot.property][dimensionIndex] = { + displayName: "Detail syntax is not supported", + } + } + }) + }) + + return errorMessages + } + + @computed get editor(): Editor | undefined { + if (!this.isReady) return undefined + + return this.manager.editor + } + + @action.bound refresh(): void { + void this.fetchDetails() + void this.fetchData() + } + + componentDidMount(): void { + this.refresh() + this.updateGrapher() + + this.disposers.push( + reaction( + () => this.editor && this.editor.previewMode, + () => { + if (this.editor) { + localStorage.setItem( + "editorPreviewMode", + this.editor.previewMode + ) + } + this.updateGrapher() + } + ) + ) + } + + disposers: IReactionDisposer[] = [] + componentWillUnmount(): void { + this.disposers.forEach((dispose) => dispose()) + } + + render(): React.ReactElement { + return ( + +
+ {(this.editor === undefined || + this.editor.currentRequest) && } + {this.editor !== undefined && this.renderReady(this.editor)} +
+
+ ) + } + + renderReady(editor: Editor): React.ReactElement { + const { grapher, availableTabs } = editor + + const chartEditor = isChartEditorInstance(editor) ? editor : undefined + const indicatorChartEditor = isIndicatorChartEditorInstance(editor) + ? editor + : undefined + + return ( + <> + {!editor.isNewGrapher && ( + + )} + {chartEditor?.newChartId && ( + + )} +
+ +
+ {editor.tab === "basic" && ( + + )} + {editor.tab === "text" && ( + + )} + {editor.tab === "data" && ( + + )} + {editor.tab === "customize" && ( + + )} + {editor.tab === "scatter" && ( + + )} + {editor.tab === "marimekko" && ( + + )} + {editor.tab === "map" && ( + + )} + {chartEditor && chartEditor.tab === "revisions" && ( + + )} + {chartEditor && chartEditor.tab === "refs" && ( + + )} + {indicatorChartEditor && + indicatorChartEditor.tab === "inheritance" && ( + + )} + {editor.tab === "export" && ( + + )} + {editor.tab === "debug" && ( + + )} +
+ {editor.tab !== "export" && ( + + )} +
+
+
+ {this.grapherElement} +
+
+
+ + +
+
+ Emulate vision deficiency:{" "} + + (this.simulateVisionDeficiency = + option.deficiency) + )} + /> +
+
+ + {/* Include svg filters necessary for vision deficiency emulation */} + +
+ + ) + } +} diff --git a/adminSiteClient/ChartList.tsx b/adminSiteClient/ChartList.tsx index 157ba0c896a..521d71aa992 100644 --- a/adminSiteClient/ChartList.tsx +++ b/adminSiteClient/ChartList.tsx @@ -27,6 +27,9 @@ export interface ChartListItem { publishedAt: string publishedBy: string + hasParentIndicator?: boolean + isInheritanceEnabled?: boolean + tags: DbChartTagJoin[] } @@ -101,6 +104,11 @@ export class ChartList extends React.Component<{ render() { const { charts, searchHighlight } = this.props const { availableTags } = this + + // if the first chart has inheritance information, we assume all charts have it + const showInheritanceColumn = + charts[0]?.isInheritanceEnabled !== undefined + return ( @@ -109,6 +117,7 @@ export class ChartList extends React.Component<{ + {showInheritanceColumn && } @@ -124,6 +133,7 @@ export class ChartList extends React.Component<{ availableTags={availableTags} searchHighlight={searchHighlight} onDelete={this.onDeleteChart} + showInheritanceColumn={showInheritanceColumn} /> ))} diff --git a/adminSiteClient/ChartRow.tsx b/adminSiteClient/ChartRow.tsx index ef1ba985090..e3fcefb6c01 100644 --- a/adminSiteClient/ChartRow.tsx +++ b/adminSiteClient/ChartRow.tsx @@ -19,6 +19,7 @@ export class ChartRow extends React.Component<{ searchHighlight?: (text: string) => string | React.ReactElement availableTags: DbChartTagJoin[] onDelete: (chart: ChartListItem) => void + showInheritanceColumn?: boolean }> { static contextType = AdminAppContext context!: AdminAppContextType @@ -40,7 +41,8 @@ export class ChartRow extends React.Component<{ } render() { - const { chart, searchHighlight, availableTags } = this.props + const { chart, searchHighlight, availableTags, showInheritanceColumn } = + this.props const highlight = searchHighlight || lodash.identity @@ -80,6 +82,7 @@ export class ChartRow extends React.Component<{ + {showInheritanceColumn && } + + // if the chart doesn't have a parent, inheritance doesn't apply + if (!chart.hasParentIndicator) return + + return chart.isInheritanceEnabled ? : +} diff --git a/adminSiteClient/ColorSchemeDropdown.tsx b/adminSiteClient/ColorSchemeDropdown.tsx index 3cc7b1b6a82..bb10e8ca5dc 100644 --- a/adminSiteClient/ColorSchemeDropdown.tsx +++ b/adminSiteClient/ColorSchemeDropdown.tsx @@ -23,6 +23,7 @@ interface ColorSchemeDropdownProps { invertedColorScheme: boolean chartType: ChartTypeName onChange: (selected: ColorSchemeOption) => void + onBlur?: () => void } @observer @@ -116,6 +117,7 @@ export class ColorSchemeDropdown extends React.Component scheme.value === this.props.value )} diff --git a/adminSiteClient/DimensionCard.tsx b/adminSiteClient/DimensionCard.tsx index cbb6ab2d9a0..4d12e0de86b 100644 --- a/adminSiteClient/DimensionCard.tsx +++ b/adminSiteClient/DimensionCard.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react" import { ChartDimension } from "@ourworldindata/grapher" import { OwidVariableRoundingMode } from "@ourworldindata/types" import { startCase } from "@ourworldindata/utils" -import { ChartEditor, DimensionErrorMessage } from "./ChartEditor.js" +import { DimensionErrorMessage } from "./ChartEditorTypes.js" import { Toggle, BindAutoString, @@ -22,11 +22,14 @@ import { } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { OwidTable } from "@ourworldindata/core-table" +import { AbstractChartEditor } from "./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..879b7ac36bb 100644 --- a/adminSiteClient/EditorBasicTab.tsx +++ b/adminSiteClient/EditorBasicTab.tsx @@ -30,7 +30,6 @@ import { } from "@ourworldindata/utils" import { FieldsRow, Section, SelectField, Toggle } from "./Forms.js" import { VariableSelector } from "./VariableSelector.js" -import { ChartEditor, ChartEditorManager } from "./ChartEditor.js" import { DimensionCard } from "./DimensionCard.js" import { DragDropContext, @@ -38,11 +37,23 @@ import { Draggable, DropResult, } from "react-beautiful-dnd" +import { AbstractChartEditor } from "./AbstractChartEditor.js" +import { EditorDatabase } from "./ChartEditorView.js" +import { isChartEditorInstance } from "./ChartEditor.js" +import { ErrorMessagesForDimensions } from "./ChartEditorTypes.js" +import { + IndicatorChartEditor, + isIndicatorChartEditorInstance, +} from "./IndicatorChartEditor.js" @observer -class DimensionSlotView extends React.Component<{ +class DimensionSlotView< + Editor extends AbstractChartEditor, +> extends React.Component<{ slot: DimensionSlot - editor: ChartEditor + editor: Editor + database: EditorDatabase + errorMessagesForDimensions: ErrorMessagesForDimensions }> { disposers: IReactionDisposer[] = [] @@ -53,8 +64,8 @@ class DimensionSlotView extends React.Component<{ } @computed - get errorMessages(): ChartEditorManager["errorMessagesForDimensions"] { - return this.props.editor.manager.errorMessagesForDimensions + get errorMessages() { + return this.props.errorMessagesForDimensions } @action.bound private onAddVariables(variableIds: OwidVariableId[]) { @@ -75,6 +86,7 @@ class DimensionSlotView extends React.Component<{ this.isSelectingVariables = false this.updateDimensionsAndRebuildTable(dimensionConfigs) + this.updateParentConfig() } @action.bound private onRemoveDimension(variableId: OwidVariableId) { @@ -83,10 +95,12 @@ class DimensionSlotView extends React.Component<{ (d) => d.variableId !== variableId ) ) + this.updateParentConfig() } @action.bound private onChangeDimension() { this.updateDimensionsAndRebuildTable() + this.updateParentConfig() } @action.bound private updateDefaults() { @@ -127,7 +141,8 @@ class DimensionSlotView extends React.Component<{ } componentDidMount() { - // We want to add the reaction only after the grapher is loaded, so we don't update the initial chart (as configured) by accident. + // We want to add the reaction only after the grapher is loaded, + // so we don't update the initial chart (as configured) by accident. when( () => this.grapher.isReady, () => { @@ -165,6 +180,13 @@ class DimensionSlotView extends React.Component<{ this.grapher.rebuildInputOwidTable() } + @action.bound private updateParentConfig() { + const { editor } = this.props + if (isChartEditorInstance(editor)) { + void editor.updateParentConfig() + } + } + @action.bound private onDragEnd(result: DropResult) { const { source, destination } = result if (!destination) return @@ -176,6 +198,7 @@ class DimensionSlotView extends React.Component<{ ) this.updateDimensionsAndRebuildTable(dimensions) + this.updateParentConfig() } @computed get isDndEnabled() { @@ -267,6 +290,7 @@ class DimensionSlotView extends React.Component<{ {isSelectingVariables && ( (this.isSelectingVariables = false) @@ -280,7 +304,13 @@ 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 + errorMessagesForDimensions: ErrorMessagesForDimensions +}> { base: React.RefObject = React.createRef() @observable.ref isAddingVariable: boolean = false @@ -296,6 +326,10 @@ class VariablesSection extends React.Component<{ editor: ChartEditor }> { key={slot.name} slot={slot} editor={props.editor} + database={props.database} + errorMessagesForDimensions={ + props.errorMessagesForDimensions + } /> ))} @@ -305,7 +339,20 @@ 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 + errorMessagesForDimensions: ErrorMessagesForDimensions +}> { + @action.bound private updateParentConfig() { + const { editor } = this.props + if (isChartEditorInstance(editor)) { + void editor.updateParentConfig() + } + } + @action.bound onChartTypeChange(value: string) { const { grapher } = this.props.editor grapher.type = value as ChartTypeName @@ -338,6 +385,11 @@ export class EditorBasicTab extends React.Component<{ editor: ChartEditor }> { property: DimensionProperty.size, }) } + + // since the parent config depends on the chart type + // (scatters don't have a parent), we might need to update + // the parent config when the type changes + this.updateParentConfig() } render() { @@ -347,8 +399,12 @@ export class EditorBasicTab extends React.Component<{ editor: ChartEditor }> { (chartType) => chartType !== ChartTypeName.WorldMap ) + const isIndicatorChart = isIndicatorChartEditorInstance(editor) + return (
+ {isIndicatorChart && } +
{ />
- + {!isIndicatorChart && ( + + )}
) } } + +function IndicatorChartInfo(props: { editor: IndicatorChartEditor }) { + const { variableId, grapher } = props.editor + + const column = grapher.inputTable.get(variableId?.toString()) + const variableLink = ( + + {column?.name ?? variableId} + + ) + + return ( +
+

This is the Grapher config for indicator {variableLink}.

+
+ ) +} diff --git a/adminSiteClient/EditorCustomizeTab.tsx b/adminSiteClient/EditorCustomizeTab.tsx index 7cc973c0820..454255fb1ea 100644 --- a/adminSiteClient/EditorCustomizeTab.tsx +++ b/adminSiteClient/EditorCustomizeTab.tsx @@ -1,7 +1,6 @@ import React from "react" import { observable, computed, action } from "mobx" import { observer } from "mobx-react" -import { ChartEditor } from "./ChartEditor.js" import { ComparisonLineConfig, ColorSchemeName, @@ -37,9 +36,14 @@ import { } from "./ColorSchemeDropdown.js" import { EditorColorScaleSection } from "./EditorColorScaleSection.js" import Select from "react-select" +import { AbstractChartEditor } from "./AbstractChartEditor.js" +import { ErrorMessages } from "./ChartEditorTypes.js" @observer -export class ColorSchemeSelector extends React.Component<{ grapher: Grapher }> { +export class ColorSchemeSelector extends React.Component<{ + grapher: Grapher + defaultValue?: ColorSchemeName +}> { @action.bound onChange(selected: ColorSchemeOption) { // The onChange method can return an array of values (when multiple // items can be selected) or a single value. Since we are certain that @@ -54,6 +58,15 @@ export class ColorSchemeSelector extends React.Component<{ grapher: Grapher }> { this.props.grapher.seriesColorMap?.clear() } + @action.bound onBlur() { + if (this.props.grapher.baseColorScheme === undefined) { + this.props.grapher.baseColorScheme = this.props.defaultValue + + // clear out saved, pre-computed colors so the color scheme change is immediately visible + this.props.grapher.seriesColorMap?.clear() + } + } + @action.bound onInvertColorScheme(value: boolean) { this.props.grapher.invertColorScheme = value || undefined @@ -69,8 +82,9 @@ export class ColorSchemeSelector extends React.Component<{ grapher: Grapher }> {
{ +class SortOrderSection< + Editor extends AbstractChartEditor, +> extends React.Component<{ editor: Editor }> { @computed get sortConfig(): SortConfig { return this.grapher._sortConfig } @@ -203,7 +219,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,13 +298,19 @@ 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() { return this.props.editor.grapher } + @computed get activeParentConfig() { + return this.props.editor.activeParentConfig + } + @computed get minTime() { return this.grapher.minTime } @@ -313,10 +337,24 @@ class TimelineSection extends React.Component<{ editor: ChartEditor }> { this.grapher.timelineMinTime = value } + @action.bound onBlurTimelineMinTime() { + if (this.grapher.timelineMinTime === undefined) { + this.grapher.timelineMinTime = + this.activeParentConfig?.timelineMinTime + } + } + @action.bound onTimelineMaxTime(value: number | undefined) { this.grapher.timelineMaxTime = value } + @action.bound onBlurTimelineMaxTime() { + if (this.grapher.timelineMaxTime === undefined) { + this.grapher.timelineMaxTime = + this.activeParentConfig?.timelineMaxTime + } + } + @action.bound onToggleHideTimeline(value: boolean) { this.grapher.hideTimeline = value || undefined } @@ -356,13 +394,23 @@ class TimelineSection extends React.Component<{ editor: ChartEditor }> { @@ -387,21 +435,25 @@ 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() { const { grapher } = this.props.editor + if (!grapher.comparisonLines) grapher.comparisonLines = [] grapher.comparisonLines.push({}) } @action.bound onRemoveComparisonLine(index: number) { const { grapher } = this.props.editor - grapher.comparisonLines!.splice(index, 1) + if (!grapher.comparisonLines) grapher.comparisonLines = [] + grapher.comparisonLines.splice(index, 1) } render() { - const { comparisonLines } = this.props.editor.grapher + const { comparisonLines = [] } = this.props.editor.grapher return (
@@ -445,18 +497,21 @@ 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 + errorMessages: ErrorMessages }> { @computed get errorMessages() { - return this.props.editor.manager.errorMessages + return this.props.errorMessages } render() { const xAxisConfig = this.props.editor.grapher.xAxis const yAxisConfig = this.props.editor.grapher.yAxis - const { features } = this.props.editor + const { features, activeParentConfig } = this.props.editor const { grapher } = this.props.editor return ( @@ -472,6 +527,12 @@ export class EditorCustomizeTab extends React.Component<{ onValue={(value) => (yAxisConfig.min = value) } + onBlur={() => { + if (yAxisConfig.min === undefined) { + yAxisConfig.min = + activeParentConfig?.yAxis?.min + } + }} allowDecimal allowNegative /> @@ -481,6 +542,12 @@ export class EditorCustomizeTab extends React.Component<{ onValue={(value) => (yAxisConfig.max = value) } + onBlur={() => { + if (yAxisConfig.max === undefined) { + yAxisConfig.max = + activeParentConfig?.yAxis?.max + } + }} allowDecimal allowNegative /> @@ -521,6 +588,15 @@ export class EditorCustomizeTab extends React.Component<{ field="label" store={yAxisConfig} errorMessage={this.errorMessages.axisLabelY} + onBlur={() => { + if ( + yAxisConfig.label === "" && + activeParentConfig?.yAxis?.label + ) { + yAxisConfig.label = + activeParentConfig.yAxis.label + } + }} /> )}
@@ -536,6 +612,12 @@ export class EditorCustomizeTab extends React.Component<{ onValue={(value) => (xAxisConfig.min = value) } + onBlur={() => { + if (xAxisConfig.min === undefined) { + xAxisConfig.min = + activeParentConfig?.yAxis?.min + } + }} allowDecimal allowNegative /> @@ -545,6 +627,12 @@ export class EditorCustomizeTab extends React.Component<{ onValue={(value) => (xAxisConfig.max = value) } + onBlur={() => { + if (xAxisConfig.max === undefined) { + xAxisConfig.max = + activeParentConfig?.yAxis?.max + } + }} allowDecimal allowNegative /> @@ -585,6 +673,15 @@ export class EditorCustomizeTab extends React.Component<{ field="label" store={xAxisConfig} errorMessage={this.errorMessages.axisLabelX} + onBlur={() => { + if ( + xAxisConfig.label === "" && + activeParentConfig?.xAxis?.label + ) { + xAxisConfig.label = + activeParentConfig.xAxis.label + } + }} /> )} @@ -592,7 +689,13 @@ export class EditorCustomizeTab extends React.Component<{
- +
{features.canSpecifySortOrder && ( diff --git a/adminSiteClient/EditorDataTab.tsx b/adminSiteClient/EditorDataTab.tsx index 14a81edf41b..f36cd92fc3b 100644 --- a/adminSiteClient/EditorDataTab.tsx +++ b/adminSiteClient/EditorDataTab.tsx @@ -8,9 +8,13 @@ import { EntityName, } from "@ourworldindata/types" import { Grapher } from "@ourworldindata/grapher" -import { ColorBox, SelectField, Section } from "./Forms.js" -import { ChartEditor } from "./ChartEditor.js" -import { faArrowsAltV, faTimes } from "@fortawesome/free-solid-svg-icons" +import { ColorBox, SelectField, Section, FieldsRow } from "./Forms.js" +import { + faArrowsAltV, + faLink, + faTimes, + faUnlink, +} from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { DragDropContext, @@ -18,6 +22,7 @@ import { Droppable, DropResult, } from "react-beautiful-dnd" +import { AbstractChartEditor } from "./AbstractChartEditor.js" interface EntityItemProps extends React.HTMLProps { grapher: Grapher @@ -84,15 +89,21 @@ class EntityItem extends React.Component { } @observer -export class KeysSection extends React.Component<{ grapher: Grapher }> { +export class KeysSection extends React.Component<{ + editor: AbstractChartEditor +}> { @observable.ref dragKey?: EntityName + @computed get editor() { + return this.props.editor + } + @action.bound onAddKey(entityName: EntityName) { - this.props.grapher.selection.selectEntity(entityName) + this.editor.grapher.selection.selectEntity(entityName) } @action.bound onDragEnd(result: DropResult) { - const { selection } = this.props.grapher + const { selection } = this.editor.grapher const { source, destination } = result if (!destination) return @@ -104,20 +115,54 @@ export class KeysSection extends React.Component<{ grapher: Grapher }> { selection.setSelectedEntities(newSelection) } + @action.bound setEntitySelectionToParentValue() { + const { grapher, activeParentConfig } = this.editor + if (!activeParentConfig || !activeParentConfig.selectedEntityNames) + return + grapher.selection.setSelectedEntities( + activeParentConfig.selectedEntityNames + ) + } + render() { - const { grapher } = this.props + const { editor } = this + const { grapher } = editor const { selection } = grapher const { unselectedEntityNames, selectedEntityNames } = selection + const isEntitySelectionInherited = editor.isPropertyInherited( + "selectedEntityNames" + ) + return (
- ({ value: key }))} - /> + + ({ value: key }))} + /> + {editor.couldPropertyBeInherited("selectedEntityNames") && ( + + )} + {(provided) => ( @@ -158,13 +203,23 @@ export class KeysSection extends React.Component<{ grapher: Grapher }> { )} + {isEntitySelectionInherited && ( +

+ + The entity selection is currently inherited from the + parent indicator. + +

+ )}
) } } @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 +263,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 @@ -274,7 +331,7 @@ export class EditorDataTab extends React.Component<{ editor: ChartEditor }> {
- + {features.canSpecifyMissingDataStrategy && ( )} diff --git a/adminSiteClient/EditorDebugTab.tsx b/adminSiteClient/EditorDebugTab.tsx new file mode 100644 index 00000000000..8495d617d66 --- /dev/null +++ b/adminSiteClient/EditorDebugTab.tsx @@ -0,0 +1,229 @@ +import React from "react" +import { observer } from "mobx-react" +import { Section, Toggle } from "./Forms.js" +import { ChartEditor, isChartEditorInstance } from "./ChartEditor.js" +import { action } from "mobx" +import { copyToClipboard, mergeGrapherConfigs } from "@ourworldindata/utils" +import YAML from "yaml" +import { notification } from "antd" +import { + IndicatorChartEditor, + isIndicatorChartEditorInstance, +} from "./IndicatorChartEditor.js" +import { AbstractChartEditor } from "./AbstractChartEditor.js" + +@observer +export class EditorDebugTab< + Editor extends AbstractChartEditor, +> extends React.Component<{ + editor: Editor +}> { + render() { + const { editor } = this.props + if (isChartEditorInstance(editor)) + return + else if (isIndicatorChartEditorInstance(editor)) + return + else return null + } +} + +@observer +class EditorDebugTabForChart extends React.Component<{ + editor: ChartEditor +}> { + @action.bound copyYamlToClipboard() { + // Avoid modifying the original JSON object + // Due to mobx memoizing computed values, the JSON can be mutated. + const patchConfig = { + ...this.props.editor.patchConfig, + } + delete patchConfig.id + delete patchConfig.dimensions + delete patchConfig.version + delete patchConfig.isPublished + const chartConfigAsYaml = YAML.stringify(patchConfig) + // Use the Clipboard API to copy the config into the users clipboard + void copyToClipboard(chartConfigAsYaml) + notification["success"]({ + message: "Copied YAML to clipboard", + description: "You can now paste this into the ETL", + placement: "bottomRight", + closeIcon: <>, + }) + } + + @action.bound onToggleInheritance(shouldBeEnabled: boolean) { + const { patchConfig, parentConfig } = this.props.editor + + // update live grapher + const newParentConfig = shouldBeEnabled ? parentConfig : undefined + const newConfig = mergeGrapherConfigs( + newParentConfig ?? {}, + patchConfig + ) + this.props.editor.updateLiveGrapher(newConfig) + + this.props.editor.isInheritanceEnabled = shouldBeEnabled + } + + render() { + const { + patchConfig, + parentConfig, + isInheritanceEnabled, + fullConfig, + parentVariableId, + grapher, + } = this.props.editor + + const column = parentVariableId + ? grapher.inputTable.get(parentVariableId.toString()) + : undefined + + const variableLink = ( + + {column?.name ?? parentVariableId} + + ) + + return ( +
+
+
Chart Id TypeInheritanceTags Published Last Updated {chart.id} {showChartType(chart)} n/aenableddisabled