From 3dc6b58de1875dbc89c446932e3634af3f1e3fa1 Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Thu, 19 Sep 2024 10:30:14 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(admin)=20allow=20timelineMin/MaxTi?= =?UTF-8?q?me=20to=20be=20overwritten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminSiteClient/EditorCustomizeTab.tsx | 188 +++++++++++------- adminSiteClient/EditorTextTab.tsx | 26 ++- adminSiteClient/Forms.tsx | 149 +++++++++----- adminSiteClient/admin.scss | 29 +++ devTools/schemaProcessor/columns.json | 8 +- .../grapher/src/core/Grapher.tsx | 12 ++ .../src/schema/defaultGrapherConfig.ts | 2 + .../src/schema/grapher-schema.005.yaml | 14 +- .../types/src/grapherTypes/GrapherTypes.ts | 4 +- 9 files changed, 282 insertions(+), 150 deletions(-) diff --git a/adminSiteClient/EditorCustomizeTab.tsx b/adminSiteClient/EditorCustomizeTab.tsx index 454255fb1ea..831fb9f3659 100644 --- a/adminSiteClient/EditorCustomizeTab.tsx +++ b/adminSiteClient/EditorCustomizeTab.tsx @@ -6,6 +6,7 @@ import { ColorSchemeName, FacetAxisDomain, FacetStrategy, + GrapherInterface, } from "@ourworldindata/types" import { Grapher } from "@ourworldindata/grapher" import { @@ -17,6 +18,7 @@ import { TextField, Button, RadioGroup, + BindAutoFloatExt, } from "./Forms.js" import { debounce, @@ -39,6 +41,91 @@ import Select from "react-select" import { AbstractChartEditor } from "./AbstractChartEditor.js" import { ErrorMessages } from "./ChartEditorTypes.js" +@observer +class TimeField< + T extends { [field: string]: any }, + K extends Extract, +> extends React.Component<{ + editor: AbstractChartEditor + field: K + store: T + label: string + defaultValue: number + defaultTextValue: TimeBoundValue +}> { + private setValue(value: number) { + this.props.store[this.props.field] = value as any + } + + @computed get currentValue(): number | undefined { + return this.props.store[this.props.field] + } + + @action.bound onChange(value: number | undefined) { + this.setValue(value ?? this.props.defaultValue) + } + + @action.bound onBlur() { + if (this.currentValue === undefined) { + this.setValue(this.props.defaultValue) + } + } + + render() { + const { editor, label, defaultTextValue, defaultValue } = this.props + + const field = this.props.field as keyof GrapherInterface + + const inheritedValue = editor.activeParentConfig?.[field] as number + const autoValue = + inheritedValue === defaultTextValue + ? defaultValue + : inheritedValue ?? defaultValue + + const input = editor.couldPropertyBeInherited(field) ? ( + store[field]} + writeFn={(store, newVal) => + (store[this.props.field] = newVal as any) + } + auto={autoValue} + isAuto={editor.isPropertyInherited(field)} + store={this.props.store} + onBlur={this.onBlur} + tooltipText={{ + auto: "Linked to parent value. Unlink by editing", + manual: "Parent value overridden. Click to reset", + }} + /> + ) : ( + + ) + + const isButtonDisabled = this.currentValue === defaultValue + + return ( +
+ {input} + +
+ ) + } +} + @observer export class ColorSchemeSelector extends React.Component<{ grapher: Grapher @@ -307,54 +394,6 @@ class TimelineSection< return this.props.editor.grapher } - @computed get activeParentConfig() { - return this.props.editor.activeParentConfig - } - - @computed get minTime() { - return this.grapher.minTime - } - @computed get maxTime() { - return this.grapher.maxTime - } - - @computed get timelineMinTime() { - return this.grapher.timelineMinTime - } - @computed get timelineMaxTime() { - return this.grapher.timelineMaxTime - } - - @action.bound onMinTime(value: number | undefined) { - this.grapher.minTime = value ?? TimeBoundValue.negativeInfinity - } - - @action.bound onMaxTime(value: number | undefined) { - this.grapher.maxTime = value ?? TimeBoundValue.positiveInfinity - } - - @action.bound onTimelineMinTime(value: number | undefined) { - 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 } @@ -364,54 +403,53 @@ class TimelineSection< } render() { - const { features } = this.props.editor + const { editor } = this.props + const { features } = editor const { grapher } = this return (
{features.timeDomain && ( - )} - {features.timelineRange && ( - - )} diff --git a/adminSiteClient/EditorTextTab.tsx b/adminSiteClient/EditorTextTab.tsx index 7112e320ba6..1a8b01639f7 100644 --- a/adminSiteClient/EditorTextTab.tsx +++ b/adminSiteClient/EditorTextTab.tsx @@ -93,11 +93,9 @@ export class EditorTextTab<
grapher.displayTitle} - writeFn={({ grapher }, newVal) => - (grapher.title = newVal) - } - readAutoFn={({ editor }) => + readFn={(grapher) => grapher.displayTitle} + writeFn={(grapher, newVal) => (grapher.title = newVal)} + auto={ editor.couldPropertyBeInherited("title") ? editor.activeParentConfig!.title : undefined @@ -106,7 +104,7 @@ export class EditorTextTab< editor.isPropertyInherited("title") || grapher.title === undefined } - store={{ grapher, editor }} + store={grapher} softCharacterLimit={100} /> {features.showEntityAnnotationInTitleToggle && ( @@ -156,11 +154,11 @@ export class EditorTextTab< /> grapher.currentSubtitle} - writeFn={({ grapher }, newVal) => + readFn={(grapher) => grapher.currentSubtitle} + writeFn={(grapher, newVal) => (grapher.subtitle = newVal) } - readAutoFn={({ editor }) => + auto={ editor.couldPropertyBeInherited("subtitle") ? editor.activeParentConfig!.subtitle : undefined @@ -169,7 +167,7 @@ export class EditorTextTab< editor.isPropertyInherited("subtitle") || grapher.subtitle === undefined } - store={{ grapher, editor }} + store={grapher} placeholder="Briefly describe the context of the data. It's best to avoid duplicating any information which can be easily inferred from other visual elements of the chart." textarea softCharacterLimit={280} @@ -192,11 +190,11 @@ export class EditorTextTab<
grapher.sourcesLine} - writeFn={({ grapher }, newVal) => + readFn={(grapher) => grapher.sourcesLine} + writeFn={(grapher, newVal) => (grapher.sourceDesc = newVal) } - readAutoFn={({ editor }) => + auto={ editor.couldPropertyBeInherited("sourceDesc") ? editor.activeParentConfig!.sourceDesc : undefined @@ -205,7 +203,7 @@ export class EditorTextTab< editor.isPropertyInherited("sourceDesc") || grapher.sourceDesc === undefined } - store={{ grapher, editor }} + store={grapher} helpText="Short comma-separated list of source names" softCharacterLimit={60} /> diff --git a/adminSiteClient/Forms.tsx b/adminSiteClient/Forms.tsx index c2e575045fe..d7fd69355f5 100644 --- a/adminSiteClient/Forms.tsx +++ b/adminSiteClient/Forms.tsx @@ -46,6 +46,7 @@ interface TextFieldProps extends React.HTMLAttributes { softCharacterLimit?: number errorMessage?: string buttonContent?: React.ReactNode + buttonDisabled?: boolean } export class TextField extends React.Component { @@ -128,6 +129,7 @@ export class TextField extends React.Component { onClick={() => props.onButtonClick && props.onButtonClick() } + disabled={props.buttonDisabled} > {props.buttonContent} @@ -251,6 +253,7 @@ interface NumberFieldProps { helpText?: string buttonContent?: React.ReactNode onButtonClick?: () => void + buttonDisabled?: boolean } interface NumberFieldState { @@ -633,6 +636,10 @@ type AutoTextFieldProps = TextFieldProps & { onToggleAuto: (value: boolean) => void onBlur?: () => void textarea?: boolean + tooltipText?: { + auto?: string + manual?: string + } } const ErrorMessage = ({ message }: { message: string }) => ( @@ -673,50 +680,36 @@ export class AutoTextField extends React.Component { const props = this.props const { textarea } = props - if (textarea) - return ( - - {props.isAuto ? ( - - ) : ( - - )} - - } - onButtonClick={() => props.onToggleAuto(!props.isAuto)} - /> - ) - else - return ( - + const Field = textarea ? TextAreaField : TextField + return ( + + {props.isAuto + ? props.tooltipText?.auto ?? + "Linked to automatic default. Unlink by editing" + : props.tooltipText?.manual ?? + "Automatic default overridden. Click to reset"} + + } + maxWidth={180} + > +
{props.isAuto ? ( ) : ( )}
- } - onButtonClick={() => props.onToggleAuto(!props.isAuto)} - /> - ) + + } + onButtonClick={() => props.onToggleAuto(!props.isAuto)} + buttonDisabled={props.isAuto} + /> + ) } } @@ -925,9 +918,9 @@ export class BindAutoStringExt< > extends React.Component< { readFn: (x: T) => string - readAutoFn?: (x: T) => string | undefined writeFn: (x: T, value: string | undefined) => void store: T + auto?: string } & Omit< AutoTextFieldProps, "onValue" | "onToggleAuto" | "value" | "isBlur" @@ -947,16 +940,14 @@ export class BindAutoStringExt< @action.bound onToggleAuto(value: boolean) { this.props.writeFn( this.props.store, - value - ? this.props.readAutoFn?.(this.props.store) - : this.props.readFn(this.props.store) + value ? this.props.auto : this.props.readFn(this.props.store) ) } render() { - const { readFn, readAutoFn, store, ...rest } = this.props + const { readFn, auto, store, ...rest } = this.props const currentReadValue = this.props.isAuto - ? readAutoFn?.(store) ?? readFn(store) + ? auto ?? readFn(store) : readFn(store) return ( void onToggleAuto: (value: boolean) => void onBlur?: () => void + tooltipText?: { + auto?: string + manual?: string + } } class AutoFloatField extends React.Component { @@ -992,19 +987,29 @@ class AutoFloatField extends React.Component { value={props.isAuto ? undefined : props.value} placeholder={props.isAuto ? props.value.toString() : undefined} buttonContent={ -
+ {props.isAuto + ? props.tooltipText?.auto ?? + "Linked to automatic default. Unlink by editing" + : props.tooltipText?.manual ?? + "Automatic default overridden. Click to reset"} +
} + maxWidth={180} > - {props.isAuto ? ( - - ) : ( - - )} - +
+ {props.isAuto ? ( + + ) : ( + + )} +
+ } onButtonClick={() => props.onToggleAuto(!props.isAuto)} + buttonDisabled={props.isAuto} /> ) } @@ -1096,6 +1101,44 @@ export class BindAutoFloat< } } +@observer +export class BindAutoFloatExt< + T extends Record, +> extends React.Component< + { + readFn: (x: T) => number + writeFn: (x: T, value: number | undefined) => void + store: T + auto?: number + } & Omit +> { + @action.bound onValue(value: number | undefined) { + this.props.writeFn(this.props.store, value) + } + + @action.bound onToggleAuto(value: boolean) { + this.props.writeFn( + this.props.store, + value ? this.props.auto : this.props.readFn(this.props.store) + ) + } + + render() { + const { readFn, auto, store, ...rest } = this.props + const currentReadValue = this.props.isAuto + ? auto ?? readFn(store) + : readFn(store) + return ( + + ) + } +} + @observer export class Modal extends React.Component<{ className?: string diff --git a/adminSiteClient/admin.scss b/adminSiteClient/admin.scss index 53c5b0c5636..8342e5a3618 100644 --- a/adminSiteClient/admin.scss +++ b/adminSiteClient/admin.scss @@ -647,6 +647,35 @@ $nav-height: 45px; } } +.InputWithActionButton { + display: flex; + flex-direction: column; + align-items: flex-start; + + .form-group { + margin-bottom: 0; + width: 100%; + } + + .ActionButton { + padding: 0; + font-size: 0.8em; + color: inherit; + + &:disabled { + font-style: italic; + } + + &:not(:disabled) { + text-decoration: underline; + } + + &:hover { + text-decoration: none; + } + } +} + .ColorBox { width: 2em; height: 2em; diff --git a/devTools/schemaProcessor/columns.json b/devTools/schemaProcessor/columns.json index 48e71bf6005..40a3cb297ee 100644 --- a/devTools/schemaProcessor/columns.json +++ b/devTools/schemaProcessor/columns.json @@ -267,9 +267,9 @@ "editor": "checkbox" }, { - "type": "integer", + "type": ["string", "number"], "pointer": "/timelineMinTime", - "editor": "numeric" + "editor": "textfield" }, { "type": "string", @@ -715,9 +715,9 @@ "enumOptions": ["independent", "shared"] }, { - "type": "integer", + "type": ["string", "number"], "pointer": "/timelineMaxTime", - "editor": "numeric" + "editor": "textfield" }, { "type": "boolean", diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index 3a133d616f0..d0f68bad2b5 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -545,6 +545,11 @@ export class Grapher if (obj.minTime) obj.minTime = minTimeToJSON(this.minTime) as any if (obj.maxTime) obj.maxTime = maxTimeToJSON(this.maxTime) as any + if (obj.timelineMinTime) + obj.timelineMinTime = minTimeToJSON(this.timelineMinTime) as any + if (obj.timelineMaxTime) + obj.timelineMaxTime = maxTimeToJSON(this.timelineMaxTime) as any + // todo: remove dimensions concept // if (this.legacyConfigAsAuthored?.dimensions) // obj.dimensions = this.legacyConfigAsAuthored.dimensions @@ -575,6 +580,13 @@ export class Grapher this.minTime = minTimeBoundFromJSONOrNegativeInfinity(obj.minTime) this.maxTime = maxTimeBoundFromJSONOrPositiveInfinity(obj.maxTime) + this.timelineMinTime = minTimeBoundFromJSONOrNegativeInfinity( + obj.timelineMinTime + ) + this.timelineMaxTime = maxTimeBoundFromJSONOrPositiveInfinity( + obj.timelineMaxTime + ) + // Todo: remove once we are more RAII. if (obj?.dimensions?.length) this.setDimensionsFromConfigs(obj.dimensions) diff --git a/packages/@ourworldindata/grapher/src/schema/defaultGrapherConfig.ts b/packages/@ourworldindata/grapher/src/schema/defaultGrapherConfig.ts index c45ec0d1224..0d9603e2085 100644 --- a/packages/@ourworldindata/grapher/src/schema/defaultGrapherConfig.ts +++ b/packages/@ourworldindata/grapher/src/schema/defaultGrapherConfig.ts @@ -32,6 +32,7 @@ export const defaultGrapherConfig = { hasChartTab: true, hideLegend: false, hideLogo: false, + timelineMinTime: "earliest", hideTimeline: false, colorScale: { equalSizeBins: true, @@ -64,6 +65,7 @@ export const defaultGrapherConfig = { canChangeScaleType: false, facetDomain: "shared", }, + timelineMaxTime: "latest", hideConnectedScatterLines: false, showNoDataArea: true, zoomToSelection: false, diff --git a/packages/@ourworldindata/grapher/src/schema/grapher-schema.005.yaml b/packages/@ourworldindata/grapher/src/schema/grapher-schema.005.yaml index 6f472f2a402..97873b45883 100644 --- a/packages/@ourworldindata/grapher/src/schema/grapher-schema.005.yaml +++ b/packages/@ourworldindata/grapher/src/schema/grapher-schema.005.yaml @@ -134,10 +134,15 @@ properties: type: boolean default: false timelineMinTime: - type: integer description: | The lowest year to show in the timeline. If this is set then the user is not able to see any data before this year. Inferred from data if not provided. + default: earliest + oneOf: + - type: number + - type: string + enum: + - earliest variantName: type: string description: Optional internal variant name for distinguishing charts with the same title @@ -423,10 +428,15 @@ properties: xAxis: $ref: "#/$defs/axis" timelineMaxTime: - type: integer description: | The highest year to show in the timeline. If this is set then the user is not able to see any data after this year. Inferred from data if not provided. + default: latest + oneOf: + - type: number + - type: string + enum: + - latest hideConnectedScatterLines: type: boolean default: false diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index 3c72fd1aeb3..bd2971e57e6 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -535,8 +535,8 @@ export interface GrapherInterface extends SortConfig { hideAnnotationFieldsInTitle?: AnnotationFieldsInTitle minTime?: TimeBound | TimeBoundValueStr maxTime?: TimeBound | TimeBoundValueStr - timelineMinTime?: Time - timelineMaxTime?: Time + timelineMinTime?: Time | TimeBoundValueStr + timelineMaxTime?: Time | TimeBoundValueStr dimensions?: OwidChartDimensionInterface[] addCountryMode?: EntitySelectionMode comparisonLines?: ComparisonLineConfig[]