From 64e34976859356ec06d907fb887b880b0c865f7c Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Thu, 18 Jul 2024 13:17:41 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminSiteClient/ChartEditor.ts | 61 +-- adminSiteClient/ChartEditorPage.tsx | 438 +---------------- adminSiteClient/ChartEditorView.tsx | 627 +++++++++++++++++++++++++ adminSiteClient/EditorBasicTab.tsx | 7 +- adminSiteClient/EditorCustomizeTab.tsx | 6 +- adminSiteClient/EditorTextTab.tsx | 8 +- adminSiteClient/SaveButtons.tsx | 5 +- 7 files changed, 693 insertions(+), 459 deletions(-) create mode 100644 adminSiteClient/ChartEditorView.tsx diff --git a/adminSiteClient/ChartEditor.ts b/adminSiteClient/ChartEditor.ts index 5b86caf0d4c..2b11f4fb260 100644 --- a/adminSiteClient/ChartEditor.ts +++ b/adminSiteClient/ChartEditor.ts @@ -100,20 +100,21 @@ export interface DimensionErrorMessage { export interface ChartEditorManager { admin: Admin grapher: Grapher - database: EditorDatabase + // database: EditorDatabase baseGrapherConfig: GrapherInterface logs: Log[] references: References | undefined redirects: ChartRedirect[] pageviews?: RawPageview - allTopics: Topic[] - details: DetailDictionary - invalidDetailReferences: DetailReferences - errorMessages: Partial> - errorMessagesForDimensions: Record< - DimensionProperty, - DimensionErrorMessage[] - > + // allTopics: Topic[] + // details: DetailDictionary + // TODO + // invalidDetailReferences: DetailReferences + // errorMessages: Partial> + // errorMessagesForDimensions: Record< + // DimensionProperty, + // DimensionErrorMessage[] + // > } interface VariableIdUsageRecord { @@ -168,9 +169,9 @@ export class ChartEditor { return this.manager.grapher } - @computed get database() { - return this.manager.database - } + // @computed get database() { + // return this.manager.database + // } @computed get logs() { return this.manager.logs @@ -188,13 +189,13 @@ export class ChartEditor { return this.manager.pageviews } - @computed get allTopics() { - return this.manager.allTopics - } + // @computed get allTopics() { + // return this.manager.allTopics + // } - @computed get details() { - return this.manager.details - } + // @computed get details() { + // return this.manager.details + // } @computed get availableTabs(): EditorTab[] { const tabs: EditorTab[] = ["basic", "data", "text", "customize"] @@ -215,18 +216,18 @@ export class ChartEditor { 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 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, diff --git a/adminSiteClient/ChartEditorPage.tsx b/adminSiteClient/ChartEditorPage.tsx index 1d533ac2a70..355a7103f16 100644 --- a/adminSiteClient/ChartEditorPage.tsx +++ b/adminSiteClient/ChartEditorPage.tsx @@ -64,142 +64,45 @@ import { import { EditorMarimekkoTab } from "./EditorMarimekkoTab.js" import { EditorExportTab } from "./EditorExportTab.js" import { runDetailsOnDemand } from "../site/detailsOnDemand.js" - -@observer -class TabBinder extends React.Component<{ editor: ChartEditor }> { - dispose!: IReactionDisposer - componentDidMount(): void { - //window.addEventListener("hashchange", this.onHashChange) - this.onHashChange() - - this.dispose = autorun(() => { - //setTimeout(() => window.location.hash = `#${tab}-tab`, 100) - }) - } - - componentWillUnmount(): void { - //window.removeEventListener("hashchange", this.onHashChange) - this.dispose() - } - - render(): null { - return null - } - - @action.bound onHashChange(): void { - const match = window.location.hash.match(/#(.+?)-tab/) - if (match) { - const tab = match[1] - if ( - this.props.editor.grapher && - this.props.editor.availableTabs.includes(tab) - ) - this.props.editor.tab = tab - } - } -} +import { ChartEditorView, ChartEditorViewManager } from "./ChartEditorView.js" @observer export class ChartEditorPage extends React.Component<{ grapherId?: number - newGrapherIndex?: number grapherConfig?: GrapherInterface }> - implements ChartEditorManager + implements ChartEditorManager, ChartEditorViewManager { @observable.ref grapher = new Grapher() - @observable.ref database = new EditorDatabase({}) + // @observable.ref database = new EditorDatabase({}) @observable logs: Log[] = [] @observable references: References | undefined = undefined @observable redirects: ChartRedirect[] = [] @observable pageviews?: RawPageview = undefined - @observable allTopics: Topic[] = [] - @observable details: DetailDictionary = {} + // @observable allTopics: Topic[] = [] + // @observable details: DetailDictionary = {} - @observable.ref grapherElement?: React.ReactElement + // @observable.ref grapherElement?: React.ReactElement static contextType = AdminAppContext context!: AdminAppContextType - @observable simulateVisionDeficiency?: VisionDeficiency + // @observable simulateVisionDeficiency?: VisionDeficiency - fetchedGrapherConfig?: GrapherInterface + fetchedGrapherConfig: GrapherInterface = {} // for now, every chart's previous config layer is the default layer baseGrapherConfig = defaultGrapherConfig async fetchGrapher(): Promise { - const { grapherId } = this.props + const { grapherId, grapherConfig } = this.props if (grapherId !== undefined) { this.fetchedGrapherConfig = await this.context.admin.getJSON( `/api/charts/${grapherId}.config.json` ) + } else if (grapherConfig) { + this.fetchedGrapherConfig = 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[], - }) - } - - 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 { @@ -244,171 +147,29 @@ 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 { + // TODO: is this a problem? this._isDbSet && this._isGrapherSet + // if (!this.isReady) return undefined return new ChartEditor({ manager: this }) } @action.bound refresh(): void { void this.fetchGrapher() - void this.fetchDetails() - void this.fetchData() void this.fetchLogs() void this.fetchRefs() void this.fetchRedirects() 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 @@ -422,175 +183,6 @@ export class ChartEditorPage } render(): React.ReactElement { - return ( - -
- {(this.editor === undefined || - this.editor.currentRequest) && } - {this.editor !== undefined && this.renderReady(this.editor)} -
-
- ) - } - - renderReady(editor: ChartEditor): React.ReactElement { - const { grapher, availableTabs } = editor - - return ( - - {!editor.newChartId && ( - - )} - {editor.newChartId && ( - - )} - -
- -
- {editor.tab === "basic" && ( - - )} - {editor.tab === "text" && ( - - )} - {editor.tab === "data" && ( - - )} - {editor.tab === "customize" && ( - - )} - {editor.tab === "scatter" && ( - - )} - {editor.tab === "marimekko" && ( - - )} - {editor.tab === "map" && ( - - )} - {editor.tab === "revisions" && ( - - )} - {editor.tab === "refs" && ( - - )} - {editor.tab === "export" && ( - - )} -
- {editor.tab !== "export" && } -
-
-
- {this.grapherElement} -
-
-
- - -
-
- Emulate vision deficiency:{" "} - - (this.simulateVisionDeficiency = - option.deficiency) - )} - /> -
-
- - {/* Include svg filters necessary for vision deficiency emulation */} - -
-
- ) + return } } diff --git a/adminSiteClient/ChartEditorView.tsx b/adminSiteClient/ChartEditorView.tsx new file mode 100644 index 00000000000..6b627ff474f --- /dev/null +++ b/adminSiteClient/ChartEditorView.tsx @@ -0,0 +1,627 @@ +import React from "react" +import { observer } from "mobx-react" +import { + observable, + computed, + runInAction, + autorun, + action, + reaction, + IReactionDisposer, +} from "mobx" +import { Prompt, Redirect } from "react-router-dom" +import { + Bounds, + capitalize, + RawPageview, + DetailDictionary, + get, + set, + groupBy, + extractDetailsFromSyntax, + getIndexableKeys, +} from "@ourworldindata/utils" +import { + Topic, + GrapherInterface, + GrapherStaticFormat, + ChartRedirect, + DimensionProperty, +} from "@ourworldindata/types" +import { defaultGrapherConfig, Grapher } from "@ourworldindata/grapher" +import { Admin } from "./Admin.js" +import { + ChartEditor, + EditorDatabase, + Log, + References, + ChartEditorManager, + Dataset, + getFullReferencesCount, + DetailReferences, + FieldWithDetailReferences, +} 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 + } + } +} + +export interface DimensionErrorMessage { + displayName?: string +} + +interface ChartEditorContextInterface { + // invalidDetailReferences?: DetailReferences + errorMessages?: Partial> + errorMessagesForDimensions?: Record< + DimensionProperty, + DimensionErrorMessage[] + > +} + +export const ChartEditorContext = + React.createContext({}) + +export interface ChartEditorViewManager { + admin: Admin + fetchedGrapherConfig: GrapherInterface + grapher: Grapher + editor: Editor // TODO: could be something else +} + +@observer +export class ChartEditorView< + Editor extends ChartEditor, // TODO: should extend abstract editor +> extends React.Component<{ + manager: ChartEditorViewManager +}> { + // @observable.ref grapher = new Grapher() + @observable.ref database = new EditorDatabase({}) + // @observable logs: Log[] = [] + // @observable references: References | undefined = undefined + // @observable redirects: ChartRedirect[] = [] + // @observable pageviews?: RawPageview = undefined + @observable allTopics: Topic[] = [] + @observable details: DetailDictionary = {} + + @observable.ref grapherElement?: React.ReactElement + + // static contextType = AdminAppContext + // context!: AdminAppContextType + + @observable simulateVisionDeficiency?: VisionDeficiency + + @computed private get manager(): ChartEditorViewManager { + return this.props.manager + } + + // fetchedGrapherConfig?: GrapherInterface + // for now, every chart's previous config layer is the default layer + // baseGrapherConfig = defaultGrapherConfig + + // async fetchGrapher(): Promise { + // const { grapherId } = this.props + // if (grapherId !== undefined) { + // this.fetchedGrapherConfig = await this.context.admin.getJSON( + // `/api/charts/${grapherId}.config.json` + // ) + // } + // this.updateGrapher() + // } + + // TODO: maybe? + @observable private _isDbSet = false + @observable private _isGrapherSet = false + @computed get isReady(): boolean { + return this._isDbSet && this._isGrapherSet + } + + @action.bound private updateGrapher(): void { + const config = this.manager.fetchedGrapherConfig + const grapherConfig = { + ...config, + // binds the grapher instance to this.grapher + getGrapherInstance: (grapher: Grapher) => { + this.manager.grapher = grapher + }, + dataApiUrlForAdmin: + this.manager.admin.settings.DATA_API_FOR_ADMIN_UI, // passed this way because clientSettings are baked and need a recompile to be updated + bounds: this.bounds, + staticFormat: this.staticFormat, + } + this.manager.grapher.renderToStatic = !!this.editor?.showStaticPreview + this.grapherElement = + this._isGrapherSet = true + } + + @action.bound private setDb(json: any): void { + this.database = new EditorDatabase(json) + this._isDbSet = true + } + + async fetchData(): Promise { + const { admin } = this.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[], + }) + } + + 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 { + // const { grapherId } = this.props + // const { admin } = this.context + // const json = + // grapherId === undefined + // ? {} + // : await admin.getJSON(`/api/charts/${grapherId}.logs.json`) + // runInAction(() => (this.logs = json.logs)) + // } + + // async fetchRefs(): Promise { + // const { grapherId } = this.props + // const { admin } = this.context + // const json = + // grapherId === undefined + // ? {} + // : await admin.getJSON( + // `/api/charts/${grapherId}.references.json` + // ) + // runInAction(() => (this.references = json.references)) + // } + + // async fetchRedirects(): Promise { + // const { grapherId } = this.props + // const { admin } = this.context + // const json = + // grapherId === undefined + // ? {} + // : await admin.getJSON(`/api/charts/${grapherId}.redirects.json`) + // runInAction(() => (this.redirects = json.redirects)) + // } + + // async fetchPageviews(): Promise { + // const { grapherId } = this.props + // const { admin } = this.context + // const json = + // grapherId === undefined + // ? {} + // : await admin.getJSON(`/api/charts/${grapherId}.pageviews.json`) + // runInAction(() => (this.pageviews = json.pageviews)) + // } + + async fetchTopics(): Promise { + const { admin } = this.context + const json = await admin.getJSON(`/api/topics.json`) + runInAction(() => (this.allTopics = json.topics)) + } + + async fetchDetails(): Promise { + await runDetailsOnDemand() + + runInAction(() => { + if (window.details) this.details = window.details + }) + } + + @computed private get isMobilePreview(): boolean { + return this.editor?.previewMode === "mobile" + } + + @computed private get bounds(): Bounds { + return this.isMobilePreview + ? new Bounds(0, 0, 380, 525) + : this.manager.grapher.defaultBounds + } + + @computed private get staticFormat(): GrapherStaticFormat { + return this.isMobilePreview + ? GrapherStaticFormat.square + : GrapherStaticFormat.landscape + } + + // unvalidated terms extracted from the subtitle and note fields + // these may point to non-existent details e.g. ["not_a_real_term", "pvotery"] + @computed + get currentDetailReferences(): DetailReferences { + const { grapher } = this.manager + return { + subtitle: extractDetailsFromSyntax(grapher.currentSubtitle), + note: extractDetailsFromSyntax(grapher.note), + axisLabelX: extractDetailsFromSyntax( + grapher.xAxisConfig.label ?? "" + ), + axisLabelY: extractDetailsFromSyntax( + grapher.yAxisConfig.label ?? "" + ), + } + } + + // the actual Detail objects, indexed by category.term + @computed get currentlyReferencedDetails(): GrapherInterface["details"] { + const grapherConfigDetails: GrapherInterface["details"] = {} + const allReferences = Object.values(this.currentDetailReferences).flat() + + allReferences.forEach((term) => { + const detail = get(this.details, term) + if (detail) { + set(grapherConfigDetails, term, detail) + } + }) + + return grapherConfigDetails + } + + @computed + get invalidDetailReferences(): DetailReferences { + const { subtitle, note, axisLabelX, axisLabelY } = + this.currentDetailReferences + return { + subtitle: subtitle.filter((term) => !this.details[term]), + note: note.filter((term) => !this.details[term]), + axisLabelX: axisLabelX.filter((term) => !this.details[term]), + axisLabelY: axisLabelY.filter((term) => !this.details[term]), + } + } + + @computed get errorMessages(): Partial< + Record + > { + const { invalidDetailReferences } = this + + const errorMessages: Partial< + Record + > = {} + + // add error messages for each field with invalid detail references + getIndexableKeys(invalidDetailReferences).forEach( + (key: FieldWithDetailReferences) => { + const references = invalidDetailReferences[key] + if (references.length) { + errorMessages[key] = + `Invalid detail(s) specified: ${references.join(", ")}` + } + } + ) + + return errorMessages + } + + @computed + get errorMessagesForDimensions(): Record< + DimensionProperty, + DimensionErrorMessage[] + > { + const errorMessages: Record< + DimensionProperty, + DimensionErrorMessage[] + > = { + [DimensionProperty.y]: [], + [DimensionProperty.x]: [], + [DimensionProperty.color]: [], + [DimensionProperty.size]: [], + [DimensionProperty.table]: [], // not used + } + + this.manager.grapher.dimensionSlots.forEach((slot) => { + slot.dimensions.forEach((dimension, dimensionIndex) => { + const details = extractDetailsFromSyntax( + dimension.display.name ?? "" + ) + const hasDetailsInDisplayName = details.length > 0 + + // add error message if details are referenced in the display name + if (hasDetailsInDisplayName) { + errorMessages[slot.property][dimensionIndex] = { + displayName: "Detail syntax is not supported", + } + } + }) + }) + + return errorMessages + } + + @computed get editor(): Editor | undefined { + if (!this.isReady) return undefined + + return this.manager.editor + } + + @action.bound refresh(): void { + void this.fetchDetails() + void this.fetchData() + + // (2024-02-15) Disabled due to slow query performance + // https://github.com/owid/owid-grapher/issues/3198 + // this.fetchTopics() + } + + disposers: IReactionDisposer[] = [] + + componentDidMount(): void { + this.refresh() + + this.disposers.push( + reaction( + () => this.editor && this.editor.previewMode, + () => { + if (this.editor) { + localStorage.setItem( + "editorPreviewMode", + this.editor.previewMode + ) + } + this.updateGrapher() + } + ) + ) + } + + // TODO: needed? + // This funny construction allows the "new chart" link to work by forcing an update + // even if the props don't change + UNSAFE_componentWillReceiveProps(): void { + setTimeout(() => this.refresh(), 0) + } + + componentWillUnmount(): void { + this.disposers.forEach((dispose) => dispose()) + } + + render(): React.ReactElement { + return ( + +
+ {(this.editor === undefined || + this.editor.currentRequest) && } + {this.editor !== undefined && this.renderReady(this.editor)} +
+
+ ) + } + + renderReady(editor: 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/adminSiteClient/EditorBasicTab.tsx b/adminSiteClient/EditorBasicTab.tsx index c2e4994e689..bf9bcc1ac2d 100644 --- a/adminSiteClient/EditorBasicTab.tsx +++ b/adminSiteClient/EditorBasicTab.tsx @@ -38,12 +38,15 @@ import { Draggable, DropResult, } from "react-beautiful-dnd" +import { ChartEditorContext } from "./ChartEditorView.js" @observer class DimensionSlotView extends React.Component<{ slot: DimensionSlot editor: ChartEditor }> { + static contextType = ChartEditorContext + disposers: IReactionDisposer[] = [] @observable.ref isSelectingVariables: boolean = false @@ -53,8 +56,8 @@ class DimensionSlotView extends React.Component<{ } @computed - get errorMessages(): ChartEditorManager["errorMessagesForDimensions"] { - return this.props.editor.manager.errorMessagesForDimensions + get errorMessages() { + return this.context.errorMessagesForDimensions } @action.bound private onAddVariables(variableIds: OwidVariableId[]) { diff --git a/adminSiteClient/EditorCustomizeTab.tsx b/adminSiteClient/EditorCustomizeTab.tsx index 7cc973c0820..215c37818b5 100644 --- a/adminSiteClient/EditorCustomizeTab.tsx +++ b/adminSiteClient/EditorCustomizeTab.tsx @@ -37,6 +37,7 @@ import { } from "./ColorSchemeDropdown.js" import { EditorColorScaleSection } from "./EditorColorScaleSection.js" import Select from "react-select" +import { ChartEditorContext } from "./ChartEditorView.js" @observer export class ColorSchemeSelector extends React.Component<{ grapher: Grapher }> { @@ -448,8 +449,11 @@ class ComparisonLineSection extends React.Component<{ editor: ChartEditor }> { export class EditorCustomizeTab extends React.Component<{ editor: ChartEditor }> { + // TODO + static contextType = ChartEditorContext + @computed get errorMessages() { - return this.props.editor.manager.errorMessages + return this.context.errorMessages } render() { diff --git a/adminSiteClient/EditorTextTab.tsx b/adminSiteClient/EditorTextTab.tsx index 0a5ba8b0220..6a3a328ebc0 100644 --- a/adminSiteClient/EditorTextTab.tsx +++ b/adminSiteClient/EditorTextTab.tsx @@ -27,9 +27,13 @@ import { TextField, Toggle, } from "./Forms.js" +import { ChartEditorContext } from "./ChartEditorView.js" @observer export class EditorTextTab extends React.Component<{ editor: ChartEditor }> { + // TODO + static contextType = ChartEditorContext + @action.bound onSlug(slug: string) { this.props.editor.grapher.slug = slugify(slug) } @@ -74,8 +78,8 @@ export class EditorTextTab extends React.Component<{ editor: ChartEditor }> { grapher.hideAnnotationFieldsInTitle.changeInPrefix = value || undefined } - @computed get errorMessages(): ChartEditorManager["errorMessages"] { - return this.props.editor.manager.errorMessages + @computed get errorMessages() { + return this.context.errorMessages } @computed get showAnyAnnotationFieldInTitleToggle() { diff --git a/adminSiteClient/SaveButtons.tsx b/adminSiteClient/SaveButtons.tsx index 1ed6ecc5c0c..e2d0c8613de 100644 --- a/adminSiteClient/SaveButtons.tsx +++ b/adminSiteClient/SaveButtons.tsx @@ -3,9 +3,12 @@ import { ChartEditor } from "./ChartEditor.js" import { action, computed } from "mobx" import { observer } from "mobx-react" import { isEmpty } from "@ourworldindata/utils" +import { ChartEditorContext } from "./ChartEditorView.js" @observer export class SaveButtons extends React.Component<{ editor: ChartEditor }> { + static contextType = ChartEditorContext + @action.bound onSaveChart() { void this.props.editor.saveGrapher() } @@ -22,7 +25,7 @@ export class SaveButtons extends React.Component<{ editor: ChartEditor }> { @computed get hasEditingErrors(): boolean { const { editor } = this.props - const { errorMessages, errorMessagesForDimensions } = editor.manager + const { errorMessages, errorMessagesForDimensions } = this.context if (!isEmpty(errorMessages)) return true