From 144cccac3c5eececc5d205493c9956e2675ca2b3 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Thu, 22 Oct 2026 22:41:43 -0400 Subject: [PATCH] Add `plugin_config` and `columns_config` defaults API Signed-off-by: Andrew Stein --- docs/md/SUMMARY.md | 1 + docs/md/how_to/javascript/config_defaults.md | 112 +++++ docs/md/how_to/javascript/save_restore.md | 25 ++ docs/md/how_to/javascript/viewer.md | 13 +- .../viewer-charts/src/ts/axis/bar-axis.ts | 2 +- .../viewer-charts/src/ts/axis/numeric-axis.ts | 6 +- .../charts/candlestick/candlestick-build.ts | 9 + .../charts/candlestick/candlestick-render.ts | 2 + .../src/ts/charts/candlestick/candlestick.ts | 1 + .../ts/charts/cartesian/cartesian-render.ts | 18 +- .../viewer-charts/src/ts/charts/chart-base.ts | 60 ++- .../charts/common/category-axis-resolver.ts | 26 +- .../src/ts/charts/common/tree-data.ts | 9 +- .../src/ts/charts/heatmap/heatmap-build.ts | 14 +- .../src/ts/charts/heatmap/heatmap-interact.ts | 7 +- .../src/ts/charts/heatmap/heatmap.ts | 2 + .../src/ts/charts/series/series-build.ts | 9 + .../src/ts/charts/series/series-render.ts | 3 + .../src/ts/charts/series/series.ts | 1 + packages/viewer-charts/src/ts/layout/ticks.ts | 97 +++-- .../src/ts/plugin/format-defaults.ts | 1 - .../test/ts/config-defaults.spec.ts | 79 ++++ .../test/ts/snapshot/config-defaults.spec.ts | 84 ++++ .../src/ts/data_listener/formatter_cache.ts | 8 +- .../test/js/column_style.spec.ts | 7 +- .../test/js/config_defaults.spec.ts | 159 +++++++ rust/metadata/main.rs | 14 +- .../src/rust/agent/tools.rs | 5 +- .../components/column_settings_sidebar.rs | 8 +- .../column_settings_sidebar/style_tab.rs | 18 +- .../rust/components/datetime_column_style.rs | 335 +++++++++------ .../datetime_column_style/custom.rs | 276 ++++-------- .../datetime_column_style/simple.rs | 156 +++---- .../src/rust/components/plugin_tab.rs | 11 +- .../src/rust/components/settings_panel.rs | 5 + .../src/rust/components/viewer/render.rs | 2 + .../src/rust/components/viewer/wiring.rs | 11 +- .../src/rust/config/column_config_schema.rs | 280 +++++++++++- .../src/rust/config/datetime_column_style.rs | 208 +++++++-- .../config/datetime_column_style/custom.rs | 138 ------ .../config/datetime_column_style/migrate.rs | 95 +++++ .../{custom_format.rs => part_format.rs} | 130 +++--- .../config/datetime_column_style/simple.rs | 65 --- .../datetime_column_style/simple_format.rs | 26 +- .../src/rust/config/number_string_format.rs | 46 ++ .../src/rust/config/options.rs | 22 + .../src/rust/config/viewer_config.rs | 24 +- .../src/rust/custom_elements/viewer.rs | 16 +- rust/perspective-viewer/src/rust/lib.rs | 105 ++++- .../src/rust/queries/plugin_column_styles.rs | 79 +--- rust/perspective-viewer/src/rust/renderer.rs | 16 +- .../src/rust/renderer/config_defaults.rs | 357 ++++++++++++++++ .../src/rust/renderer/plugin_config.rs | 399 ++++++++++++++++-- .../src/rust/renderer/props.rs | 4 + .../src/rust/tasks/apply_config_defaults.rs | 84 ++++ .../src/rust/tasks/create_panel.rs | 5 +- rust/perspective-viewer/src/rust/tasks/mod.rs | 2 + .../src/rust/tasks/pipeline.rs | 68 ++- .../src/rust/tasks/send_column_config.rs | 10 +- .../src/rust/tasks/send_plugin_config.rs | 5 +- .../src/rust/tasks/sync_update_panels.rs | 6 +- .../src/rust/tasks/transactional_restore.rs | 2 - .../src/ts/column-format.ts | 227 +++++----- rust/perspective-viewer/src/ts/extensions.ts | 71 +++- .../test/html/debug_plugins.js | 97 ++++- .../column_settings/config_defaults.spec.ts | 179 ++++++++ .../column_settings/datetime_format.spec.ts | 244 +++++++++++ .../column_settings/format_defaults.spec.ts | 7 +- .../js/viewer_api/config_defaults.spec.ts | 394 +++++++++++++++++ .../date_format_migration.spec.ts | 120 ++++++ 70 files changed, 4042 insertions(+), 1085 deletions(-) create mode 100644 docs/md/how_to/javascript/config_defaults.md create mode 100644 packages/viewer-charts/test/ts/config-defaults.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/config-defaults.spec.ts create mode 100644 packages/viewer-datagrid/test/js/config_defaults.spec.ts delete mode 100644 rust/perspective-viewer/src/rust/config/datetime_column_style/custom.rs create mode 100644 rust/perspective-viewer/src/rust/config/datetime_column_style/migrate.rs rename rust/perspective-viewer/src/rust/config/datetime_column_style/{custom_format.rs => part_format.rs} (51%) delete mode 100644 rust/perspective-viewer/src/rust/config/datetime_column_style/simple.rs create mode 100644 rust/perspective-viewer/src/rust/renderer/config_defaults.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/apply_config_defaults.rs create mode 100644 rust/perspective-viewer/test/js/column_settings/config_defaults.spec.ts create mode 100644 rust/perspective-viewer/test/js/column_settings/datetime_format.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/config_defaults.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_config/date_format_migration.spec.ts diff --git a/docs/md/SUMMARY.md b/docs/md/SUMMARY.md index 79cfeea98f..243005fa1a 100644 --- a/docs/md/SUMMARY.md +++ b/docs/md/SUMMARY.md @@ -58,6 +58,7 @@ - [Arranging panels: layouts and dashboards](./how_to/javascript/layout.md) - [Listening for events](./how_to/javascript/events.md) - [Plugin render limits](./how_to/javascript/plugin_settings.md) + - [Configuration defaults](./how_to/javascript/config_defaults.md) - [Map tile sources](./how_to/javascript/map_tile_sources.md) - [Configuring the LLM agent](./how_to/javascript/agent.md) - [Virtual Servers](./how_to/javascript/virtual_server.md) diff --git a/docs/md/how_to/javascript/config_defaults.md b/docs/md/how_to/javascript/config_defaults.md new file mode 100644 index 0000000000..6300b4e566 --- /dev/null +++ b/docs/md/how_to/javascript/config_defaults.md @@ -0,0 +1,112 @@ +# Configuration defaults + +Every plugin declares a default for each `plugin_config` and `columns_config` +key it understands. Two static methods on the `` element +class replace those defaults for every viewer on the page: + +```javascript +const Viewer = customElements.get("perspective-viewer"); + +// Every plugin that declares `group_pad` now defaults it to 10. +Viewer.setPluginConfigDefaults({ group_pad: 10 }); + +// Datetime columns render in UTC in every plugin. +Viewer.setColumnConfigDefaults( + { date_format: { timeZone: "UTC" } }, + { type: "datetime" }, +); +``` + +A replaced default behaves exactly like the plugin's own: + +- The settings and column style editors show it as the unedited value. +- A value equal to it is left out of `save()`, `saveWorkspace()` and the + `perspective-config-update` event, so a column nobody edited saves nothing. +- The plugin renders it wherever the stored config leaves the key out. +- `restore()` drops a value equal to it, like any other default. + +Defaults are part of the host application, like registered plugins and themes. +They are never saved, and `restore()` and `reset()` never change them. + +## Narrowing + +Both methods take an `options` object: + +| Option | Method | Effect | +| -------- | ------------------------- | -------------------------------------------------------------------------------------------------------- | +| `plugin` | both | Applies only to the named plugin, e.g. `"Datagrid"` or `"X/Y Line"` | +| `type` | `setColumnConfigDefaults` | Applies only to columns of this view type: `integer`, `float`, `string`, `boolean`, `date` or `datetime` | + +Without `plugin`, a default applies to every plugin that declares the key and is +ignored by the rest. When several defaults set the same key, the most specific +wins: + +| Precedence (low to high) | +| ----------------------------------------------------- | +| The plugin's declared default | +| `setColumnConfigDefaults(defaults)` | +| `setColumnConfigDefaults(defaults, { type })` | +| `setColumnConfigDefaults(defaults, { plugin })` | +| `setColumnConfigDefaults(defaults, { plugin, type })` | +| The column's own stored config | + +`plugin_config` defaults follow the same order without the `type` levels. + +## Merging and removing + +Each call merges into the defaults set by earlier calls with the same options. +`null` removes a default at any depth: + +| Call | Effect | +| ------------------------------------- | -------------------------------------------- | +| `{ group_pad: 10 }` | Sets `group_pad`, keeping everything else | +| `{ group_pad: null }` | Removes `group_pad` | +| `{ date_format: { timeZone: null } }` | Removes only `timeZone` from `date_format` | +| `null` | Removes every default set with these options | + +`getPluginConfigDefaults()` and `getColumnConfigDefaults()` return the current +defaults, one entry per combination of options. + +## Formats + +`date_format` and `number_format` defaults combine with the plugin's declared +format and with a column's stored format by group rather than as a whole: + +| Format | Applies as one group | Applies individually | +| --------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------- | +| `date_format` | `dateStyle`, `timeStyle`, `weekday`, `year`, `month`, `day`, `hour`, `minute`, `second`, `fractionalSecondDigits` | `timeZone`, `hour12` | +| `number_format` | `style` and its `currency*`/`unit*` options; `notation` and `compactDisplay` | everything else | + +So a page-wide `timeZone` reaches a column whose user picked a long date, while +a default `dateStyle` never mixes with a column's own per-part fields. A +`number_format` default that sets one digit bound inherits the other from the +plugin, so `{ maximumFractionDigits: 4 }` on a datagrid float column means 2 to +4 digits. + +In the charts, a `date_format` default that sets only `timeZone` or `hour12` +keeps the adaptive axis labels, which choose their precision from the zoom +level. A default that sets a display field, or any `number_format` default, +fixes that type's axis labels to the format, as a format chosen in the column +style editor does. + +## Setting defaults before restoring + +Call these methods before `load()` and `restore()`. `restore()` drops values +equal to the defaults in force at that moment, so a config restored first loses +any value equal to the plugin's own default, and a default set afterwards then +replaces it. + +Saved configs leave out values equal to the defaults. Restoring one in an +application without the same defaults renders the plugins' own defaults instead. + +A default for a key whose plugin default comes from the column's data, such as a +gradient's maximum, replaces that computed value with the fixed one. + +## Errors + +A call throws when its arguments are malformed: `defaults` is not an object or +`null`, `plugin` names no registered plugin, `type` is not a column type, or a +`date_format` mixes `dateStyle`/`timeStyle` with per-part fields. A value that a +particular plugin cannot use, such as an `Enum` value outside its choices or a +number outside its range, is logged as an error naming the plugin, column and +key, and that plugin keeps its own default. diff --git a/docs/md/how_to/javascript/save_restore.md b/docs/md/how_to/javascript/save_restore.md index 9e89a9977b..665fef6a82 100644 --- a/docs/md/how_to/javascript/save_restore.md +++ b/docs/md/how_to/javascript/save_restore.md @@ -184,6 +184,31 @@ name. Only the single-panel `restore()` accepts the inline form — in See [Derived tables](../../explanation/derived_table.md). +## Datetime formats + +A `date` or `datetime` column's `date_format` is a subset of +`Intl.DateTimeFormatOptions`: either the `dateStyle`/`timeStyle` presets or +per-part fields (`weekday`, `year`, `month`, `day`, `hour`, `minute`, `second`, +`fractionalSecondDigits`), never both, plus optional `timeZone` and `hour12`. As +in `Intl`, a display field that is left out is not shown: + +```javascript +await viewer.restore({ + columns_config: { + "Order Date": { date_format: { dateStyle: "long", timeZone: "UTC" } }, + }, +}); +``` + +A column inherits the plugin's display fields until it sets any of them, and +inherits `timeZone` and `hour12` individually. + +Configs saved before 5.7.0 used a `"disabled"` value and a `format: "custom"` +marker, and a missing preset field meant `short` date or `medium` time. +`restore()` rewrites these into the current form with the same rendering. A +hand-written config with no `version` and a missing preset field takes the +current meaning: `{ dateStyle: "long" }` now shows the date alone. + ## Colors, palettes and gradients Per-column color styling lives in a panel's `columns_config`, keyed by column diff --git a/docs/md/how_to/javascript/viewer.md b/docs/md/how_to/javascript/viewer.md index 9e4939c7f4..19e25d6df8 100644 --- a/docs/md/how_to/javascript/viewer.md +++ b/docs/md/how_to/javascript/viewer.md @@ -177,6 +177,15 @@ for charts and text for the datagrid. | `restyleElement()` | Re-read CSS and repaint | | `getPlugin(name?)` / `getAllPlugins()` | Look up registered plugins | +The element class also has page-wide static methods: + +| Method | Description | +|---|---| +| `registerPlugin(name)` | Register a plugin custom element | +| `setPluginConfigDefaults(defaults, options?)` / `setColumnConfigDefaults(defaults, options?)` | Replace plugins' `plugin_config` / `columns_config` defaults | +| `getPluginConfigDefaults()` / `getColumnConfigDefaults()` | Read the replaced defaults | + See [Saving and restoring UI state](./save_restore.md) for the `save`/`restore` -formats and the panel selector, and -[Plugin render limits](./plugin_settings.md) for `getPlugin`. +formats and the panel selector, +[Plugin render limits](./plugin_settings.md) for `getPlugin`, and +[Configuration defaults](./config_defaults.md) for the defaults methods. diff --git a/packages/viewer-charts/src/ts/axis/bar-axis.ts b/packages/viewer-charts/src/ts/axis/bar-axis.ts index 6f47a2ee04..19beb24d97 100644 --- a/packages/viewer-charts/src/ts/axis/bar-axis.ts +++ b/packages/viewer-charts/src/ts/axis/bar-axis.ts @@ -37,7 +37,7 @@ function tickFormatter( return override; } - return stepTickFormatter(domain.isDate, ticks); + return stepTickFormatter(domain.isDate, ticks, domain.dateModifiers); } /** diff --git a/packages/viewer-charts/src/ts/axis/numeric-axis.ts b/packages/viewer-charts/src/ts/axis/numeric-axis.ts index 4fcc6e7fd3..0174e7efd0 100644 --- a/packages/viewer-charts/src/ts/axis/numeric-axis.ts +++ b/packages/viewer-charts/src/ts/axis/numeric-axis.ts @@ -21,12 +21,16 @@ import { drawYTickColumn, } from "./axis-primitives"; import type { Theme } from "../theme/theme"; +import type { DateFormatModifiers } from "@perspective-dev/viewer/column-format"; export interface AxisDomain { min: number; max: number; label: string; isDate?: boolean; + + /** The `timeZone`/`hour12` adaptive date labels on this axis apply. */ + dateModifiers?: DateFormatModifiers; } export interface TickResult { @@ -77,7 +81,7 @@ function tickFmt( return override; } - return stepTickFormatter(domain.isDate, ticks); + return stepTickFormatter(domain.isDate, ticks, domain.dateModifiers); } /** diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts index 3d0e713c81..ddffbae682 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { DateFormatModifiers } from "@perspective-dev/viewer/column-format"; import type { ColumnDataMap } from "../../data/view-reader"; import { buildSplitGroups } from "../../data/split-groups"; import type { CategoricalLevel } from "../../axis/categorical-axis"; @@ -156,6 +157,9 @@ export interface CandlestickPipelineInput { */ groupByTypes: Record; + /** Per-column `timeZone`/`hour12` adaptive date labels apply. */ + dateModifiers?: ReadonlyMap; + /** * Band-slot geometry knobs sourced from * {@link PluginConfig.band_inner_frac} / `bar_inner_pad`. Forwarded @@ -230,6 +234,7 @@ export function buildCandlestickPipeline( groupBy, splitBy, groupByTypes, + dateModifiers, bandInnerFrac, barInnerPad, scratchCandles, @@ -282,6 +287,7 @@ export function buildCandlestickPipeline( groupBy.length, levelTypes, axisMode, + groupBy.map((name) => dateModifiers?.get(name)), ); if (numCategories === 0) { return { @@ -321,6 +327,9 @@ export function buildCandlestickPipeline( groupBy[0] ?? "", axisMode.numericType === "date" || axisMode.numericType === "datetime", + axisMode.numericType === "datetime" + ? dateModifiers?.get(groupBy[0]) + : undefined, ); if (resolved) { categoryPositions = resolved.categoryPositions; diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts index 68c4a3446f..91cd2680c5 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts @@ -270,6 +270,7 @@ function paintCandlestickChromeOverlay(chart: CandlestickChart): void { max: chart._numericCategoryDomain.max, isDate: chart._numericCategoryDomain.isDate, label: chart._numericCategoryDomain.label, + dateModifiers: chart._numericCategoryDomain.dateModifiers, }, ticks: chart._lastCatTicks, }; @@ -355,6 +356,7 @@ function renderCandlestickTooltip(chart: CandlestickChart): void { stepTickFormatter( chart._numericCategoryDomain.isDate, chart._lastCatTicks, + chart._numericCategoryDomain.dateModifiers, ); categoryText = catFmt(chart._categoryPositions[candles.catIdx[i]]); } else { diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts index 6279734d1d..8496f89492 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts @@ -264,6 +264,7 @@ export class CandlestickChart extends CategoricalYChart { groupBy: this._groupBy, splitBy: this._splitBy, groupByTypes: this._groupByTypes, + dateModifiers: this._columnDateModifiers, bandInnerFrac: this._pluginConfig.band_inner_frac, barInnerPad: this._pluginConfig.bar_inner_pad, scratchCandles: this._candles, diff --git a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts index 5780c8c78f..74b30fafd3 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts @@ -288,6 +288,9 @@ function buildXDomain( label: chart._xLabel || (chart._xIsRowIndex ? "Row" : chart._xName || ""), isDate, + dateModifiers: isDate + ? chart.getDateModifiers(chart._xName) + : undefined, }; } @@ -302,6 +305,9 @@ function buildYDomain( max, label: chart._yLabel || chart._yName, isDate, + dateModifiers: isDate + ? chart.getDateModifiers(chart._yName) + : undefined, }; } @@ -1424,10 +1430,18 @@ function cartesianAxisIndicators( } else { const xFmt = xOverride ?? - stepTickFormatter(chart._lastXDomain?.isDate, chart._lastXTicks); + stepTickFormatter( + chart._lastXDomain?.isDate, + chart._lastXTicks, + chart._lastXDomain?.dateModifiers, + ); const yFmt = yOverride ?? - stepTickFormatter(chart._lastYDomain?.isDate, chart._lastYTicks); + stepTickFormatter( + chart._lastYDomain?.isDate, + chart._lastYTicks, + chart._lastYDomain?.dateModifiers, + ); xText = xFmt(dataX); yText = yFmt(dataY); } diff --git a/packages/viewer-charts/src/ts/charts/chart-base.ts b/packages/viewer-charts/src/ts/charts/chart-base.ts index 5145f35faf..251e606747 100644 --- a/packages/viewer-charts/src/ts/charts/chart-base.ts +++ b/packages/viewer-charts/src/ts/charts/chart-base.ts @@ -15,9 +15,12 @@ import { createNumberFormatter, createDatetimeFormatter, createDateFormatter, + dateFormatModifiers, + hasDateFormatShape, sourceColumn, type NumberFormatConfig, type DateFormatConfig, + type DateFormatModifiers, } from "@perspective-dev/viewer/column-format"; import type { ColumnDataMap } from "../data/view-reader"; import { LazyRowFetcher } from "../data/lazy-row"; @@ -213,6 +216,15 @@ export abstract class AbstractChart implements ChartImplementation { * caller falls back to the chart's hand-rolled tick formatter. */ _columnFormatters: Map string> = new Map(); + + /** + * Per-column `timeZone`/`hour12` of a datetime `date_format` that sets + * no display fields, which adaptive axis labels apply. + */ + _columnDateModifiers: Map = new Map(); + + /** Per-column value formatters for those same columns. */ + _columnValueFormatters: Map string> = new Map(); _defaultChartType: string | undefined = undefined; _facetConfig: FacetConfig = { ...DEFAULT_FACET_CONFIG }; @@ -437,6 +449,7 @@ export abstract class AbstractChart implements ChartImplementation { setGroupByTypes(schema: Record): void { this._groupByTypes = schema; + this._rebuildColumnFormatters(); } setColumnsConfig(cfg: Record): void { @@ -448,12 +461,14 @@ export abstract class AbstractChart implements ChartImplementation { /** * Rebuild {@link _columnFormatters} from `_columnsConfig` + - * `_columnTypes`. Called from both `setColumnsConfig` and - * `setColumnTypes` since either side of the (config, types) pair - * can arrive first depending on the host's restore order. Idempotent. + * `_columnTypes` / `_groupByTypes`. Called from `setColumnsConfig`, + * `setColumnTypes` and `setGroupByTypes` since the config and types + * can arrive in any order. Idempotent. */ private _rebuildColumnFormatters(): void { this._columnFormatters = new Map(); + this._columnDateModifiers = new Map(); + this._columnValueFormatters = new Map(); for (const [name, columnCfg] of Object.entries(this._columnsConfig)) { // `_columnTypes` is the post-aggregation `view.schema()` map // and doesn't key group_by source columns; fall back to @@ -466,6 +481,21 @@ export abstract class AbstractChart implements ChartImplementation { if (fmt) { this._columnFormatters.set(name, fmt); } + + const dateFormat = columnCfg?.date_format as + | DateFormatConfig + | undefined; + + const modifiers = dateFormatModifiers(dateFormat); + if (type === "datetime" && modifiers && !fmt) { + this._columnDateModifiers.set(name, modifiers); + const intl = createDatetimeFormatter( + dateFormat, + CHART_DATETIME_DEFAULTS, + ); + + this._columnValueFormatters.set(name, (v) => intl.format(v)); + } } } @@ -495,7 +525,7 @@ export abstract class AbstractChart implements ChartImplementation { if (type === "datetime") { const dateFormat = cfg.date_format as DateFormatConfig | undefined; - if (!dateFormat) { + if (!hasDateFormatShape(dateFormat)) { return undefined; } @@ -508,7 +538,7 @@ export abstract class AbstractChart implements ChartImplementation { if (type === "date") { const dateFormat = cfg.date_format as DateFormatConfig | undefined; - if (!dateFormat) { + if (!hasDateFormatShape(dateFormat)) { return undefined; } @@ -560,6 +590,14 @@ export abstract class AbstractChart implements ChartImplementation { return undefined; } + const valueFormatter = columnName + ? this._columnValueFormatters.get(sourceColumn(columnName)) + : undefined; + + if (valueFormatter) { + return valueFormatter; + } + // `_columnTypes` is the post-aggregation schema and doesn't // key group_by source columns (their post-aggregate form is // `__ROW_PATH_N__`); fall back to `_groupByTypes` so date / @@ -576,6 +614,18 @@ export abstract class AbstractChart implements ChartImplementation { return DEFAULT_VALUE_FORMATTER; } + /** + * The `timeZone`/`hour12` adaptive labels for `columnName` apply, when + * its `date_format` sets only those. + */ + getDateModifiers( + columnName: string | null | undefined, + ): DateFormatModifiers | undefined { + return columnName + ? this._columnDateModifiers.get(sourceColumn(columnName)) + : undefined; + } + setDefaultChartType(chartType: string): void { this._defaultChartType = chartType; } diff --git a/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts b/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts index f2701f4f32..1b7cb8f2d1 100644 --- a/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts +++ b/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts @@ -17,6 +17,7 @@ import type { } from "../../axis/categorical-axis"; import { buildGroupRuns } from "../../axis/categorical-axis-core"; import { formatTickValue, formatDateTickValue } from "../../layout/ticks"; +import type { DateFormatModifiers } from "@perspective-dev/viewer/column-format"; export interface CategoryAxisResult { /** @@ -62,6 +63,9 @@ export interface NumericCategoryDomain { * Data-unit width of one category band, from min adjacent delta. */ bandWidth: number; + + /** The `timeZone`/`hour12` adaptive date labels on this axis apply. */ + dateModifiers?: DateFormatModifiers; } /** @@ -80,6 +84,7 @@ export function resolveNumericCategoryDomain( rowOffset: number, label: string, isDate: boolean, + dateModifiers?: DateFormatModifiers, ): { categoryPositions: Float64Array; numericCategoryDomain: NumericCategoryDomain; @@ -123,6 +128,7 @@ export function resolveNumericCategoryDomain( isDate, label, bandWidth: minDelta, + dateModifiers: isDate ? dateModifiers : undefined, }, }; } @@ -186,6 +192,7 @@ function formatLevelValue( value: number, valid: boolean, levelType: string, + dateModifiers?: DateFormatModifiers, ): string { // `value` can be `undefined` even when `valid` claims otherwise — an // out-of-bounds read when a row window outran the delivered Arrow @@ -199,10 +206,14 @@ function formatLevelValue( return value ? "true" : "false"; } - if (levelType === "date" || levelType === "datetime") { + if (levelType === "date") { return formatDateTickValue(value); } + if (levelType === "datetime") { + return formatDateTickValue(value, undefined, dateModifiers); + } + if (levelType === "integer") { return String(value | 0); } @@ -225,6 +236,7 @@ export function synthesizeStringLevel( rp: ColumnData, numRows: number, levelType: string, + dateModifiers?: DateFormatModifiers, ): { indices: Int32Array; dictionary: string[] } { const values = rp.values!; const valid = rp.valid; @@ -245,7 +257,7 @@ export function synthesizeStringLevel( const v = values[r] as number; let dictIdx = valueIdx.get(v); if (dictIdx === undefined) { - const label = formatLevelValue(v, true, levelType); + const label = formatLevelValue(v, true, levelType, dateModifiers); dictIdx = seen.get(label); if (dictIdx === undefined) { dictIdx = dictionary.length; @@ -283,6 +295,7 @@ export function resolveCategoryAxis( groupByLen: number, levelTypes: string[] = [], axisMode?: AxisMode, + levelDateModifiers: (DateFormatModifiers | undefined)[] = [], ): CategoryAxisResult { if (axisMode?.mode === "numeric") { const rp = columns.get("__ROW_PATH_0__"); @@ -325,7 +338,14 @@ export function resolveCategoryAxis( }); } else if (rp.values) { const levelType = levelTypes[n] ?? "string"; - rawRowPaths.push(synthesizeStringLevel(rp, numRows, levelType)); + rawRowPaths.push( + synthesizeStringLevel( + rp, + numRows, + levelType, + levelDateModifiers[n], + ), + ); } else { break; } diff --git a/packages/viewer-charts/src/ts/charts/common/tree-data.ts b/packages/viewer-charts/src/ts/charts/common/tree-data.ts index c3ea2452ba..76f726de4b 100644 --- a/packages/viewer-charts/src/ts/charts/common/tree-data.ts +++ b/packages/viewer-charts/src/ts/charts/common/tree-data.ts @@ -312,7 +312,14 @@ export function processTreeChunk( // Uses the same formatter as `resolveCategoryAxis`. const levelName = chart._groupBy[n]; const levelType = chart._groupByTypes[levelName] ?? "string"; - rpCols.push(synthesizeStringLevel(rp, rp.values.length, levelType)); + rpCols.push( + synthesizeStringLevel( + rp, + rp.values.length, + levelType, + chart.getDateModifiers(levelName), + ), + ); } else { break; } diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts index f7c9b2e441..5aabc16c37 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { DateFormatModifiers } from "@perspective-dev/viewer/column-format"; import type { ColumnDataMap } from "../../data/view-reader"; import type { CategoricalLevel } from "../../axis/categorical-axis"; import { buildGroupRuns } from "../../axis/categorical-axis-core"; @@ -40,6 +41,9 @@ export interface HeatmapPipelineInput { * decision when there's a single split_by. */ groupByTypes: Record; + + /** Per-column `timeZone`/`hour12` adaptive date labels apply. */ + dateModifiers?: ReadonlyMap; } export interface HeatmapPipelineResult { @@ -100,7 +104,8 @@ export interface HeatmapPipelineResult { export function buildHeatmapPipeline( input: HeatmapPipelineInput, ): HeatmapPipelineResult { - const { columns, numRows, groupBy, splitBy, groupByTypes } = input; + const { columns, numRows, groupBy, splitBy, groupByTypes, dateModifiers } = + input; const xAxisMode = resolveAxisMode(groupBy, groupByTypes); @@ -134,6 +139,7 @@ export function buildHeatmapPipeline( groupBy.length, levelTypes, xAxisMode, + groupBy.map((name) => dateModifiers?.get(name)), ); // Numeric X domain: sourced from `__ROW_PATH_0__`'s raw values when @@ -149,6 +155,9 @@ export function buildHeatmapPipeline( groupBy[0] ?? "", xAxisMode.numericType === "date" || xAxisMode.numericType === "datetime", + xAxisMode.numericType === "datetime" + ? dateModifiers?.get(groupBy[0]) + : undefined, ); if (resolved) { xPositions = resolved.categoryPositions; @@ -202,6 +211,9 @@ export function buildHeatmapPipeline( splitBy[0] ?? "", yAxisModeRaw.numericType === "date" || yAxisModeRaw.numericType === "datetime", + yAxisModeRaw.numericType === "datetime" + ? dateModifiers?.get(splitBy[0]) + : undefined, ); if (resolved) { yAxisMode = yAxisModeRaw; diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts index 6a067d1ddc..ab19974112 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { DateFormatModifiers } from "@perspective-dev/viewer/column-format"; import type { HeatmapChart } from "./heatmap"; import type { HeatmapCell } from "./heatmap-build"; import { renderCanvasTooltip } from "../../interaction/tooltip-controller"; @@ -223,7 +224,10 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void { let yLevels: CategoricalLevel[]; let xPositions: Float64Array | null; let yPositions: Float64Array | null; - let xNumericDomain: { isDate?: boolean } | null; + let xNumericDomain: { + isDate?: boolean; + dateModifiers?: DateFormatModifiers; + } | null; let facetLabel: string | null = null; if (chart._hoveredFacetIdx >= 0) { @@ -297,6 +301,7 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void { stepTickFormatter( xNumericDomain?.isDate, computeNiceTicks(layout.paddedXMin, layout.paddedXMax, 6), + xNumericDomain?.dateModifiers, ) )(xPositions[cell.xIdx]) : xPath; diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts index ee06f9e800..efa2770790 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts @@ -226,6 +226,7 @@ export class HeatmapChart extends AbstractChart { groupBy: this._groupBy, splitBy: this._splitBy, groupByTypes: this._groupByTypes, + dateModifiers: this._columnDateModifiers, }); const instanceStart = allCells.length; @@ -307,6 +308,7 @@ export class HeatmapChart extends AbstractChart { groupBy: this._groupBy, splitBy: this._splitBy, groupByTypes: this._groupByTypes, + dateModifiers: this._columnDateModifiers, }); this._facets = []; diff --git a/packages/viewer-charts/src/ts/charts/series/series-build.ts b/packages/viewer-charts/src/ts/charts/series/series-build.ts index db2fa8a246..285a83a633 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-build.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-build.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { DateFormatModifiers } from "@perspective-dev/viewer/column-format"; import type { ColumnDataMap } from "../../data/view-reader"; import { buildSplitGroups } from "../../data/split-groups"; import type { @@ -252,6 +253,9 @@ export interface SeriesPipelineInput { * mode for single-level group_bys. */ groupByTypes: Record; + + /** Per-column `timeZone`/`hour12` adaptive date labels apply. */ + dateModifiers?: ReadonlyMap; columnsConfig: Record | undefined; /** @@ -423,6 +427,7 @@ export function buildSeriesPipeline( groupBy, splitBy, groupByTypes, + dateModifiers, columnsConfig, defaultChartType, autoAltYAxis, @@ -488,6 +493,7 @@ export function buildSeriesPipeline( groupBy.length, levelTypes, axisMode, + groupBy.map((name) => dateModifiers?.get(name)), ); if (numCategories === 0) { @@ -589,6 +595,9 @@ export function buildSeriesPipeline( groupBy[0] ?? "", axisMode.numericType === "date" || axisMode.numericType === "datetime", + axisMode.numericType === "datetime" + ? dateModifiers?.get(groupBy[0]) + : undefined, ); if (resolved) { categoryPositions = resolved.categoryPositions; diff --git a/packages/viewer-charts/src/ts/charts/series/series-render.ts b/packages/viewer-charts/src/ts/charts/series/series-render.ts index 986d34e4fd..ef46bbece3 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-render.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-render.ts @@ -1171,6 +1171,7 @@ function paintBarChromeOverlay(chart: SeriesChart): void { max: chart._numericCategoryDomain.max, isDate: chart._numericCategoryDomain.isDate, label: chart._numericCategoryDomain.label, + dateModifiers: chart._numericCategoryDomain.dateModifiers, }, ticks: chart._lastCatTicks, }; @@ -1332,6 +1333,7 @@ function renderFacetedBarChromeOverlay(chart: SeriesChart): void { max: chart._numericCategoryDomain!.max, isDate: chart._numericCategoryDomain!.isDate, label: chart._numericCategoryDomain!.label, + dateModifiers: chart._numericCategoryDomain!.dateModifiers, } : null; @@ -1726,6 +1728,7 @@ function renderBarTooltipCanvas(chart: SeriesChart): void { stepTickFormatter( chart._numericCategoryDomain?.isDate, chart._lastCatTicks, + chart._numericCategoryDomain?.dateModifiers, ); categoryText = catFmt(chart._categoryPositions[b.catIdx]); } else { diff --git a/packages/viewer-charts/src/ts/charts/series/series.ts b/packages/viewer-charts/src/ts/charts/series/series.ts index 793935d4d8..70f8ab8f3c 100644 --- a/packages/viewer-charts/src/ts/charts/series/series.ts +++ b/packages/viewer-charts/src/ts/charts/series/series.ts @@ -602,6 +602,7 @@ export class SeriesChart extends CategoricalYChart { groupBy: this._groupBy, splitBy: this._splitBy, groupByTypes: this._groupByTypes, + dateModifiers: this._columnDateModifiers, columnsConfig: this._columnsConfig, defaultChartType: this._defaultChartType as | "bar" diff --git a/packages/viewer-charts/src/ts/layout/ticks.ts b/packages/viewer-charts/src/ts/layout/ticks.ts index 31cc5c0b01..2cbf09287b 100644 --- a/packages/viewer-charts/src/ts/layout/ticks.ts +++ b/packages/viewer-charts/src/ts/layout/ticks.ts @@ -10,6 +10,8 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { DateFormatModifiers } from "@perspective-dev/viewer/column-format"; + function niceNum(value: number, round: boolean): number { const exp = Math.floor(Math.log10(value)); const frac = value / Math.pow(10, exp); @@ -72,13 +74,14 @@ export function computeNiceTicks( export function stepTickFormatter( isDate: boolean | undefined, ticks: number[] | null | undefined, + modifiers?: DateFormatModifiers, ): (v: number) => string { if (!isDate) { return formatTickValue; } const step = ticks && ticks.length > 1 ? ticks[1] - ticks[0] : 0; - return (v: number) => formatDateTickValue(v, step); + return (v: number) => formatDateTickValue(v, step, modifiers); } /** @@ -128,19 +131,21 @@ export function formatTickValue(val: number): string { * toLocale*` constructs a fresh `DateTimeFormat` (plus its ICU * backing) on EVERY call — ~30µs each — which turned per-row label * synthesis over large pivots into a multi-second stall. A cached - * formatter's `format()` is ~1µs. Keyed by precision tier; the + * formatter's `format()` is ~1µs. Keyed by precision tier and date options; the * default locale is fixed for the lifetime of the worker, so entries * never invalidate. */ const DATE_FORMAT_CACHE = new Map(); function cachedDateFormat( - key: string, + tier: string, options: Intl.DateTimeFormatOptions, + modifiers?: DateFormatModifiers, ): Intl.DateTimeFormat { + const key = `${tier}|${modifiers?.timeZone ?? ""}|${modifiers?.hour12 ?? ""}`; let fmt = DATE_FORMAT_CACHE.get(key); if (!fmt) { - fmt = new Intl.DateTimeFormat(undefined, options); + fmt = new Intl.DateTimeFormat(undefined, { ...options, ...modifiers }); DATE_FORMAT_CACHE.set(key, fmt); } @@ -151,7 +156,11 @@ function cachedDateFormat( * Format a timestamp (ms since epoch) as a human-readable date/time label. * Adapts precision based on the tick spacing. */ -export function formatDateTickValue(val: number, stepMs?: number): string { +export function formatDateTickValue( + val: number, + stepMs?: number, + modifiers?: DateFormatModifiers, +): string { const d = new Date(val); if (isNaN(d.getTime())) { return formatTickValue(val); @@ -165,49 +174,73 @@ export function formatDateTickValue(val: number, stepMs?: number): string { if (stepMs >= DAY * 28) { // Monthly or longer — show year-month - return cachedDateFormat("ym", { - year: "numeric", - month: "short", - }).format(d); + return cachedDateFormat( + "ym", + { + year: "numeric", + month: "short", + }, + modifiers, + ).format(d); } if (stepMs >= DAY) { // Daily — show month and day - return cachedDateFormat("md", { - month: "short", - day: "numeric", - }).format(d); + return cachedDateFormat( + "md", + { + month: "short", + day: "numeric", + }, + modifiers, + ).format(d); } if (stepMs >= HOUR) { // Hourly - return cachedDateFormat("mdh", { - month: "short", - day: "numeric", - hour: "numeric", - }).format(d); + return cachedDateFormat( + "mdh", + { + month: "short", + day: "numeric", + hour: "numeric", + }, + modifiers, + ).format(d); } if (stepMs >= MINUTE) { // Minutes - return cachedDateFormat("hm", { - hour: "numeric", - minute: "2-digit", - }).format(d); + return cachedDateFormat( + "hm", + { + hour: "numeric", + minute: "2-digit", + }, + modifiers, + ).format(d); } // Sub-minute - return cachedDateFormat("hms", { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }).format(d); + return cachedDateFormat( + "hms", + { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }, + modifiers, + ).format(d); } // Default: show date only - return cachedDateFormat("ymd", { - year: "numeric", - month: "short", - day: "numeric", - }).format(d); + return cachedDateFormat( + "ymd", + { + year: "numeric", + month: "short", + day: "numeric", + }, + modifiers, + ).format(d); } diff --git a/packages/viewer-charts/src/ts/plugin/format-defaults.ts b/packages/viewer-charts/src/ts/plugin/format-defaults.ts index 50d0190006..f486ea38b6 100644 --- a/packages/viewer-charts/src/ts/plugin/format-defaults.ts +++ b/packages/viewer-charts/src/ts/plugin/format-defaults.ts @@ -29,7 +29,6 @@ export const CHART_NUMBER_DEFAULTS: NumberFormatConfig = { export const CHART_DATETIME_DEFAULTS: DateFormatConfig = { dateStyle: "medium", - timeStyle: "disabled", }; export const CHART_DATE_DEFAULTS: DateFormatConfig = { diff --git a/packages/viewer-charts/test/ts/config-defaults.spec.ts b/packages/viewer-charts/test/ts/config-defaults.spec.ts new file mode 100644 index 0000000000..88ba24e8ea --- /dev/null +++ b/packages/viewer-charts/test/ts/config-defaults.spec.ts @@ -0,0 +1,79 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; +import { expect, test } from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; +import { + gotoBasic, + restoreChart, + sweepPinnedTooltips, + tooltipValue, +} from "./helpers"; + +const DATETIME_X: ViewerConfigUpdate = { + plugin: "X/Y Scatter", + columns: ["Order Date", "Sales"], + group_by: [], + split_by: [], + sort: [["Order Date", "asc"]], +} as ViewerConfigUpdate; + +async function datesBySales(page: Page): Promise> { + const out = new Map(); + for (const cells of await sweepPinnedTooltips(page)) { + const sales = tooltipValue(cells, "Sales"); + const date = tooltipValue(cells, "Order Date"); + if (sales !== undefined && date !== undefined) { + out.set(sales, date); + } + } + + return out; +} + +test.describe("Charts under config defaults", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("tooltips follow a timezone default", async ({ page }) => { + test.setTimeout(120_000); + await restoreChart(page, DATETIME_X); + const before = await datesBySales(page); + await page.evaluate(async () => { + const Viewer = customElements.get("perspective-viewer") as any; + Viewer.setColumnConfigDefaults( + { date_format: { timeZone: "Pacific/Honolulu" } }, + { type: "datetime" }, + ); + + await document.querySelector("perspective-viewer")!.flush(); + }); + + const after = await datesBySales(page); + const shared = [...after.keys()].filter((sales) => before.has(sales)); + expect(shared.length).toBeGreaterThan(0); + for (const sales of shared) { + expect(after.get(sales)).not.toBe(before.get(sales)); + } + + const config = await page.evaluate( + async () => + (await document + .querySelector("perspective-viewer")! + .save()) as any, + ); + + expect(config.columns_config).toEqual({}); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/config-defaults.spec.ts b/packages/viewer-charts/test/ts/snapshot/config-defaults.spec.ts new file mode 100644 index 0000000000..746ddb6d53 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/config-defaults.spec.ts @@ -0,0 +1,84 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test } from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; +import { expectViewerScreenshot, gotoBasic, restoreChart } from "../helpers"; + +async function setColumnDefaults( + page: Page, + defaults: Record, + options: Record, +) { + await page.evaluate( + ({ defaults, options }) => { + const Viewer = customElements.get("perspective-viewer") as any; + Viewer.setColumnConfigDefaults(defaults, options); + }, + { defaults, options }, + ); +} + +test.describe("Config defaults", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("a timezone default keeps adaptive axis labels", async ({ page }) => { + await setColumnDefaults( + page, + { date_format: { timeZone: "Pacific/Honolulu" } }, + { type: "datetime" }, + ); + + await restoreChart(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + }); + + await expectViewerScreenshot(page); + }); + + test("a timezone default applies to datetime category labels", async ({ + page, + }) => { + await setColumnDefaults( + page, + { date_format: { timeZone: "Pacific/Honolulu" } }, + { type: "datetime" }, + ); + + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Order Date", "Region"], + filter: [["Sales", ">", 1000]], + } as never); + + await expectViewerScreenshot(page); + }); + + test("a display default fixes axis labels", async ({ page }) => { + await setColumnDefaults( + page, + { date_format: { dateStyle: "long" } }, + { type: "datetime" }, + ); + + await restoreChart(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + }); + + await expectViewerScreenshot(page); + }); +}); diff --git a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts index 1633398dbe..9de4494419 100644 --- a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts +++ b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts @@ -67,11 +67,11 @@ export class FormatterCache { } get(type: ColumnType, plugin: PluginConfig): Formatter | false | undefined { - const formatter_key = [ + const formatter_key = JSON.stringify([ type, - ...Object.values(plugin.date_format ?? {}), - ...Object.values(plugin.number_format ?? {}), - ].join("-"); + plugin.date_format ?? null, + plugin.number_format ?? null, + ]); if (!this._formatters.has(formatter_key)) { if (type === "date") { diff --git a/packages/viewer-datagrid/test/js/column_style.spec.ts b/packages/viewer-datagrid/test/js/column_style.spec.ts index 7510fe8bf8..c61247f86c 100644 --- a/packages/viewer-datagrid/test/js/column_style.spec.ts +++ b/packages/viewer-datagrid/test/js/column_style.spec.ts @@ -569,8 +569,7 @@ test.describe("Column Style Tests", () => { columns_config: { "Order Date": { date_format: { - date_style: "full", - time_style: "medium", + dateStyle: "full", }, }, }, @@ -601,8 +600,8 @@ test.describe("Column Style Tests", () => { columns_config: { "Order Date": { date_format: { - date_style: "long", - time_style: "long", + dateStyle: "long", + timeStyle: "long", }, }, }, diff --git a/packages/viewer-datagrid/test/js/config_defaults.spec.ts b/packages/viewer-datagrid/test/js/config_defaults.spec.ts new file mode 100644 index 0000000000..02e4076dcc --- /dev/null +++ b/packages/viewer-datagrid/test/js/config_defaults.spec.ts @@ -0,0 +1,159 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { expect, test } from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +async function restore(page: Page, config: Record) { + await page.evaluate(async (config) => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore(config); + await viewer.flush(); + }, config); +} + +async function setColumnDefaults( + page: Page, + defaults: Record | null, + options?: Record, +) { + await page.evaluate( + async ({ defaults, options }) => { + const Viewer = customElements.get("perspective-viewer") as any; + Viewer.setColumnConfigDefaults(defaults, options); + await document.querySelector("perspective-viewer")!.flush(); + }, + { defaults, options }, + ); +} + +async function firstCell(page: Page, name: string): Promise { + return await page.evaluate((name) => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + + const headers = [ + ...datagrid.regular_table.querySelectorAll( + "thead tr:last-of-type th", + ), + ]; + + const index = headers.findIndex( + (th: Element) => th.textContent?.trim() === name, + ); + + const row = datagrid.regular_table.querySelector("tbody tr"); + return row.children[index].textContent.trim(); + }, name); +} + +async function savedColumnsConfig(page: Page) { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + return (await viewer.save()).columns_config; + }); +} + +test.describe("Datagrid under config defaults", () => { + test("a timezone default formats datetime cells", async ({ page }) => { + await restore(page, { + plugin: "Datagrid", + columns: ["Order Date", "Sales"], + }); + + const expected = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + const view = await viewer.getView(); + const cols = await view.to_columns({ start_row: 0, end_row: 1 }); + return new Intl.DateTimeFormat(navigator.languages as string[], { + dateStyle: "short", + timeStyle: "medium", + timeZone: "Pacific/Honolulu", + }).format(cols["Order Date"][0]); + }); + + const before = await firstCell(page, "Order Date"); + await setColumnDefaults( + page, + { date_format: { timeZone: "Pacific/Honolulu" } }, + { type: "datetime" }, + ); + + const after = await firstCell(page, "Order Date"); + expect(after).not.toBe(before); + expect(after).toBe(expected); + expect(await savedColumnsConfig(page)).toEqual({}); + }); + + test("a resize saves only the width", async ({ page }) => { + await setColumnDefaults( + page, + { date_format: { timeZone: "Pacific/Honolulu" } }, + { type: "datetime" }, + ); + + await setColumnDefaults( + page, + { number_format: { maximumFractionDigits: 4 } }, + { type: "float" }, + ); + + await restore(page, { + plugin: "Datagrid", + columns: ["Order Date", "Sales"], + }); + + for (const index of [0, 1]) { + const handle = page + .locator( + "perspective-viewer-datagrid regular-table thead tr:last-of-type th .rt-column-resize", + ) + .nth(index); + + const pos = await handle.boundingBox(); + await page.mouse.move(pos!.x + 2, pos!.y + 5); + await page.mouse.down(); + await page.mouse.move(pos!.x + 80, pos!.y + 5); + await page.mouse.up(); + await expect + .poll( + async () => + Object.keys(await savedColumnsConfig(page)).length, + ) + .toBe(index + 1); + + await page.waitForTimeout(600); + } + + await expect + .poll(async () => + Object.keys(await savedColumnsConfig(page)).sort(), + ) + .toEqual(["Order Date", "Sales"]); + + const saved = await savedColumnsConfig(page); + expect(Object.keys(saved["Order Date"])).toEqual([ + "column_size_override", + ]); + expect(Object.keys(saved["Sales"])).toEqual(["column_size_override"]); + }); +}); diff --git a/rust/metadata/main.rs b/rust/metadata/main.rs index 7c9a039f36..7e2a157050 100644 --- a/rust/metadata/main.rs +++ b/rust/metadata/main.rs @@ -38,11 +38,11 @@ use perspective_client::{ }; use perspective_js::TypedArrayWindow; use perspective_viewer::config::{ - AddPanelOptions, ClientOptions, CustomNumberFormatConfig, DatetimeFormatType, ExportMethod, - ExportOptions, GetClientOptions, GetTableOptions, GetViewOptions, Notation, NumberFormatStyle, - PanelOptions, PluginStaticConfig, RestoreOptions, RestoreWorkspaceOptions, - SaveWorkspaceOptions, ViewerConfig, ViewerConfigInitial, ViewerConfigUpdate, WorkspaceConfig, - WorkspaceConfigUpdate, + AddPanelOptions, ClientOptions, ColumnConfigDefaultsOptions, ConfigDefaultsOptions, + CustomNumberFormatConfig, DatetimeFormat, ExportMethod, ExportOptions, GetClientOptions, + GetTableOptions, GetViewOptions, Notation, NumberFormatStyle, PanelOptions, PluginStaticConfig, + RestoreOptions, RestoreWorkspaceOptions, SaveWorkspaceOptions, ViewerConfig, + ViewerConfigInitial, ViewerConfigUpdate, WorkspaceConfig, WorkspaceConfigUpdate, }; use ts_rs::TS; @@ -75,6 +75,8 @@ pub fn generate_type_bindings_viewer() -> Result<(), Box> { PluginStaticConfig::export_all_to(&path)?; OnUpdateData::export_all_to(&path)?; SaveWorkspaceOptions::export_all_to(&path)?; + ConfigDefaultsOptions::export_all_to(&path)?; + ColumnConfigDefaultsOptions::export_all_to(&path)?; // The column-format wire types (`columns_config` values): the // flattened style/notation families export separately (ts-rs cannot @@ -82,7 +84,7 @@ pub fn generate_type_bindings_viewer() -> Result<(), Box> { CustomNumberFormatConfig::export_all_to(&path)?; NumberFormatStyle::export_all_to(&path)?; Notation::export_all_to(&path)?; - DatetimeFormatType::export_all_to(&path)?; + DatetimeFormat::export_all_to(&path)?; Ok(()) } diff --git a/rust/perspective-viewer/src/rust/agent/tools.rs b/rust/perspective-viewer/src/rust/agent/tools.rs index d81da9cb90..46008c244e 100644 --- a/rust/perspective-viewer/src/rust/agent/tools.rs +++ b/rust/perspective-viewer/src/rust/agent/tools.rs @@ -855,7 +855,7 @@ fn control_schema_entries(spec: &ControlSpec) -> Vec<(String, Value)> { }, ControlSpec::DatetimeFormat { .. } => vec![( "date_format".to_owned(), - json!({ "description": "Datetime display format: a style preset or custom format fields" }), + json!({ "description": "Datetime display format, a subset of `Intl.DateTimeFormatOptions`: either `dateStyle`/`timeStyle` presets or per-part fields (`weekday`, `year`, `month`, `day`, `hour`, `minute`, `second`, `fractionalSecondDigits`), never both, plus optional `timeZone` and `hour12`; a missing display field is not shown" }), )], ControlSpec::NumberSeriesStyle { .. } => vec![ ( @@ -1413,10 +1413,9 @@ impl PerspectiveViewerElement { get_column_config_schema( &panel.renderer, &view_config, - &metadata, + &panel.session, column, current, - None, ) .ok(), ) diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs index 95a86ca20a..b10ebf8afb 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs @@ -35,7 +35,7 @@ use crate::components::type_icon::TypeIconType; use crate::components::window_editor::WindowEditorProps; use crate::presentation::{ColumnLocator, ColumnSettingsTab, Presentation}; use crate::queries::{has_column_config, is_pivot_only_column}; -use crate::renderer::Renderer; +use crate::renderer::{ConfigDefaultsSlots, Renderer}; use crate::session::{Session, SessionMetadataRc}; use crate::tasks::{ delete_expr, delete_window, save_expr, save_window, update_expr, update_window, @@ -71,6 +71,10 @@ pub struct ColumnSettingsPanelProps { /// Active plugin name. pub plugin_name: Option, + /// The page-wide config defaults the Style tab's schema applies. + #[derivative(Debug = "ignore")] + pub config_defaults: PtrEqRc, + /// Session metadata snapshot — threaded from `SessionProps`. pub metadata: SessionMetadataRc, @@ -102,6 +106,7 @@ impl PartialEq for ColumnSettingsPanelProps { self.selected_column == other.selected_column && self.selected_tab == other.selected_tab && self.plugin_name == other.plugin_name + && self.config_defaults == other.config_defaults && self.metadata == other.metadata && self.view_config == other.view_config && self.column_stats == other.column_stats @@ -490,6 +495,7 @@ impl Component for ColumnSettingsPanel { view_config: ctx.props().view_config.clone(), metadata: ctx.props().metadata.clone(), column_stats: ctx.props().column_stats.clone(), + config_defaults: ctx.props().config_defaults.clone(), selected_theme: ctx.props().selected_theme.clone(), presentation: ctx.props().presentation.clone(), renderer: ctx.props().renderer.clone(), diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs index dc56d948a8..f81e46cb01 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs @@ -35,7 +35,8 @@ use crate::components::number_series_style::NumberSeriesStyle; use crate::components::style_controls::CustomNumberFormat; use crate::config::{ Alignment, ColumnConfigFieldUpdate, ControlSpec, CustomNumberFormatConfig, - DatetimeColumnStyleConfig, FontToggle, NumberFormatDefaults, NumberSeriesStyleConfig, + DatetimeColumnStyleConfig, DatetimeFormat, FontToggle, NumberFormatDefaults, + NumberSeriesStyleConfig, }; use crate::presentation::Presentation; use crate::queries::{fetch_column_abs_max, get_column_config_schema, named_values}; @@ -61,6 +62,9 @@ pub struct StyleTabProps { /// Per-column stats snapshot — threaded from `SessionProps`. pub column_stats: PtrEqRc>, + /// The page-wide config defaults the schema applies. + pub config_defaults: PtrEqRc, + /// Selected theme name, threaded for PortalModal consumers. pub selected_theme: Option, @@ -85,11 +89,6 @@ pub fn StyleTab(props: &StyleTabProps) -> Html { // is cleared on `view_config_changed`; on cache miss we spawn the // fetch and let `column_stats_changed` drive the re-render via // `SessionProps`. - let abs_max = props - .column_stats - .get(&props.column_name) - .and_then(|s| s.abs_max); - yew::use_effect_with((props.column_name.clone(), props.view_config.clone()), { let session = props.session.clone(); let column_name = props.column_name.clone(); @@ -121,10 +120,9 @@ pub fn StyleTab(props: &StyleTabProps) -> Html { let schema = get_column_config_schema( &props.renderer, &props.view_config, - &props.metadata, + &props.session, &props.column_name, raw_config.as_ref(), - abs_max, ); let on_change = { @@ -262,7 +260,9 @@ fn render_leaf(spec: ControlSpec, keys: &[String], ctx: &FieldRenderCtx) -> Opti ControlSpec::DatetimeFormat { default } => { let config: Option = deser_sub(raw_config); let enable_time_config = props.ty.unwrap() == ColumnType::Datetime; - let default_format = Rc::new(default.unwrap_or_default()); + let default_format = Rc::new( + DatetimeFormat::base(enable_time_config).overlay(&default.unwrap_or_default()), + ); html! { , - /// The active plugin's declared default `date_format`. - pub default_format: Rc, + /// The format the column inherits when it stores none. + pub default_format: Rc, #[prop_or_default] pub on_change: Callback, @@ -65,15 +64,64 @@ impl PartialEq for DatetimeColumnStyleProps { } } +/// A display-field edit, applied to the shown shape given the shape a reset +/// restores. +pub type ShapeEdit = Rc DatetimeFormat>; + pub enum DatetimeColumnStyleMsg { - SimpleDatetimeStyleConfigChanged(SimpleDatetimeStyleConfig), - CustomDatetimeStyleConfigChanged(CustomDatetimeStyleConfig), + ShapeChanged(ShapeEdit), + ToParts, + ToPreset, TimezoneChanged(Option), + Hour12Changed(Option), +} + +/// A display field's select value: one of its forms, or not shown. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PartChoice { + Shown(T), + Hidden, +} + +impl Display for PartChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Shown(x) => x.fmt(f), + Self::Hidden => f.write_str("disabled"), + } + } +} + +impl From> for PartChoice { + fn from(value: Option) -> Self { + value.map_or(Self::Hidden, Self::Shown) + } +} + +impl PartChoice { + /// The field value this choice stores, given the value a reset restores. + pub fn resolve(choice: Option, reset: Option) -> Option { + match choice { + None => reset, + Some(Self::Hidden) => None, + Some(Self::Shown(x)) => Some(x), + } + } +} + +/// Every form of `T`, then [`PartChoice::Hidden`] when `hideable`. +pub fn part_choices(hideable: bool) -> Rc>> { + let mut values: Vec<_> = T::iter().map(PartChoice::Shown).collect(); + if hideable { + values.push(PartChoice::Hidden); + } + + Rc::new(values) } #[derive(Debug)] pub struct DatetimeColumnStyle { - config: DatetimeColumnStyleConfig, + stored: DatetimeFormat, } impl Component for DatetimeColumnStyle { @@ -83,124 +131,170 @@ impl Component for DatetimeColumnStyle { fn create(ctx: &Context) -> Self { ctx.set_modal_link(); Self { - config: ctx.props().config.clone().unwrap_or_default(), + stored: stored_format(ctx.props()), } } fn changed(&mut self, ctx: &Context, old: &Self::Properties) -> bool { - let mut rerender = false; - let mut new_config = ctx.props().config.clone().unwrap_or_default(); - if self.config != new_config { - std::mem::swap(&mut self.config, &mut new_config); - rerender = true; - } - if old.enable_time_config != ctx.props().enable_time_config - || old.default_format != ctx.props().default_format - { - rerender = true; - } + let stored = stored_format(ctx.props()); + let rerender = self.stored != stored + || old.enable_time_config != ctx.props().enable_time_config + || old.default_format != ctx.props().default_format; + + self.stored = stored; rerender } fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - DatetimeColumnStyleMsg::TimezoneChanged(val) => { - let mut format = self.effective_format(ctx); - if Some(&*USER_TIMEZONE) != val.as_ref() { - *format.time_zone_mut() = val; + let inherited = &*ctx.props().default_format; + let with_time = ctx.props().enable_time_config; + let next = match msg { + DatetimeColumnStyleMsg::ShapeChanged(edit) => { + let effective = inherited.overlay(&self.stored); + let reset_shape = if effective.has_parts() { + parts_reset_shape(inherited, with_time) } else { - *format.time_zone_mut() = None; + preset_reset_shape(inherited, with_time) + }; + + let shape = edit(&effective.shape(), &reset_shape); + if !shape.has_shape() { + return false; } - self.set_format(ctx, format); - self.dispatch_config(ctx); - true + self.stored.with_shape(&shape) }, - DatetimeColumnStyleMsg::SimpleDatetimeStyleConfigChanged(simple) => { - self.set_format(ctx, DatetimeFormatType::Simple(simple)); - self.dispatch_config(ctx); - true + DatetimeColumnStyleMsg::ToParts => self + .stored + .with_shape(&parts_reset_shape(inherited, with_time)), + DatetimeColumnStyleMsg::ToPreset => self + .stored + .with_shape(&preset_reset_shape(inherited, with_time)), + DatetimeColumnStyleMsg::TimezoneChanged(time_zone) => DatetimeFormat { + time_zone: time_zone.filter(|tz| *tz != inherited_time_zone(inherited)), + ..self.stored.clone() }, - DatetimeColumnStyleMsg::CustomDatetimeStyleConfigChanged(custom) => { - self.set_format(ctx, DatetimeFormatType::Custom(custom)); - self.dispatch_config(ctx); - true + DatetimeColumnStyleMsg::Hour12Changed(hour12) => DatetimeFormat { + hour12: hour12.filter(|h| *h != inherited_hour12(inherited)), + ..self.stored.clone() }, + }; + + self.stored = next.diff(inherited); + let mut value = serde_json::Map::new(); + if !self.stored.is_empty() + && let Ok(format) = serde_json::to_value(&self.stored) + { + value.insert("date_format".to_owned(), format); } - } - fn view(&self, ctx: &Context) -> Html { - let format = self.effective_format(ctx); - let (date_style_default, time_style_default) = match &*ctx.props().default_format { - DatetimeFormatType::Simple(simple) => (simple.date_style, simple.time_style), - DatetimeFormatType::Custom(_) => { - (SimpleDatetimeFormat::Short, SimpleDatetimeFormat::Medium) - }, - }; + ctx.props().on_change.emit(ColumnConfigFieldUpdate { + keys: ctx.props().keys.clone(), + value, + }); - let simple_reset = { - let default_format = ctx.props().default_format.clone(); - ctx.link().callback(move |_| { - DatetimeColumnStyleMsg::SimpleDatetimeStyleConfigChanged(match &*default_format { - DatetimeFormatType::Simple(simple) => simple.clone(), - DatetimeFormatType::Custom(_) => SimpleDatetimeStyleConfig::default(), - }) - }) - }; + true + } + fn view(&self, ctx: &Context) -> Html { + let inherited = &*ctx.props().default_format; + let with_time = ctx.props().enable_time_config; + let effective = inherited.overlay(&self.stored); + let hour_values = Rc::new(vec!["12 Hour", "24 Hour"]); + let hour_label = |hour12: bool| if hour12 { "12 Hour" } else { "24 Hour" }; html! { - <> -
- if ctx.props().enable_time_config { - - label="timezone" - values={ALL_TIMEZONES.with(|x| (*x).clone())} - default_value={(*USER_TIMEZONE).clone()} - on_change={ctx.link().callback(DatetimeColumnStyleMsg::TimezoneChanged)} - current_value={format.time_zone().as_ref().unwrap_or(&*USER_TIMEZONE).clone()} - /> +
+ if with_time { + + label="timezone" + values={ALL_TIMEZONES.with(|x| (*x).clone())} + default_value={inherited_time_zone(inherited)} + on_change={ctx.link().callback(DatetimeColumnStyleMsg::TimezoneChanged)} + current_value={inherited_time_zone(&effective)} + /> + + label="hours" + values={hour_values} + default_value={hour_label(inherited_hour12(inherited))} + on_change={ctx.link().callback(|x: Option<&'static str>| DatetimeColumnStyleMsg::Hour12Changed(x.map(|x| x == "12 Hour")))} + current_value={hour_label(inherited_hour12(&effective))} + /> + } + if effective.has_parts() { + if with_time { +
+
} - if let DatetimeFormatType::Simple(config) = &format { - if ctx.props().enable_time_config { -
-
- } - - } else if let DatetimeFormatType::Custom(config) = &format { - if ctx.props().enable_time_config { -
-
- } - + + } else { + if with_time { +
+
} -
- + + } +
} } } +fn parts_reset_shape(inherited: &DatetimeFormat, with_time: bool) -> DatetimeFormat { + if inherited.has_parts() { + inherited.shape() + } else { + DatetimeFormat::parts_base(with_time) + } +} + +fn preset_reset_shape(inherited: &DatetimeFormat, with_time: bool) -> DatetimeFormat { + if inherited.has_preset() { + inherited.shape() + } else { + DatetimeFormat::base(with_time) + } +} + +fn stored_format(props: &DatetimeColumnStyleProps) -> DatetimeFormat { + props + .config + .as_ref() + .and_then(|config| config.date_format.clone()) + .unwrap_or_default() +} + +fn inherited_time_zone(format: &DatetimeFormat) -> String { + format + .time_zone + .clone() + .unwrap_or_else(|| (*USER_TIMEZONE).clone()) +} + +fn inherited_hour12(format: &DatetimeFormat) -> bool { + format.hour12.unwrap_or(*USER_HOUR12) +} + #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_name = supportedValuesOf, js_namespace = Intl)] @@ -228,32 +322,13 @@ static USER_TIMEZONE: LazyLock = LazyLock::new(|| { .unwrap() }); -impl DatetimeColumnStyle { - fn effective_format(&self, ctx: &Context) -> DatetimeFormatType { - self.config - .date_format - .clone() - .unwrap_or_else(|| (*ctx.props().default_format).clone()) - } - - fn set_format(&mut self, ctx: &Context, format: DatetimeFormatType) { - self.config.date_format = (format != *ctx.props().default_format).then_some(format); - } - - /// When this config has changed, we must signal the wrapper element. - fn dispatch_config(&self, ctx: &Context) { - let value = if self.config == DatetimeColumnStyleConfig::default() { - serde_json::Map::new() - } else { - match serde_json::to_value(&self.config) { - Ok(serde_json::Value::Object(m)) => m, - _ => serde_json::Map::new(), - } - }; - - ctx.props().on_change.emit(ColumnConfigFieldUpdate { - keys: ctx.props().keys.clone(), - value, - }); - } -} +static USER_HOUR12: LazyLock = LazyLock::new(|| { + js_sys::Reflect::get( + &js_sys::Intl::DateTimeFormat::new(&navigator().languages(), &json!({"hour": "numeric"})) + .resolved_options(), + &JsValue::from("hour12"), + ) + .ok() + .and_then(|x| x.as_bool()) + .unwrap_or(true) +}); diff --git a/rust/perspective-viewer/src/rust/components/datetime_column_style/custom.rs b/rust/perspective-viewer/src/rust/components/datetime_column_style/custom.rs index a0d33ed680..86f3adfbbe 100644 --- a/rust/perspective-viewer/src/rust/components/datetime_column_style/custom.rs +++ b/rust/perspective-viewer/src/rust/components/datetime_column_style/custom.rs @@ -10,211 +10,117 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +use std::fmt::Display; use std::rc::Rc; +use strum::IntoEnumIterator; use yew::prelude::*; +use super::{PartChoice, ShapeEdit, part_choices}; use crate::config::*; -use crate::ui::{ModalLink, NumberField, SelectValueField, SetModalLink}; -use crate::utils::WeakScope; +use crate::ui::{NumberField, SelectValueField}; -#[derive(Properties)] +#[derive(Properties, PartialEq)] pub struct DatetimeStyleCustomProps { pub enable_time_config: bool, - pub config: CustomDatetimeStyleConfig, + /// The per-part shape shown. + pub shape: DatetimeFormat, - #[prop_or_default] - pub on_change: Callback, + /// The per-part shape a field's reset restores. + pub default_shape: DatetimeFormat, - #[prop_or_default] - weak_link: WeakScope, + pub on_change: Callback, } -impl ModalLink for DatetimeStyleCustomProps { - fn weak_link(&self) -> &'_ WeakScope { - &self.weak_link - } +fn shown_parts(format: &DatetimeFormat) -> usize { + [ + format.weekday.is_some(), + format.year.is_some(), + format.month.is_some(), + format.day.is_some(), + format.hour.is_some(), + format.minute.is_some(), + format.second.is_some(), + ] + .into_iter() + .filter(|shown| *shown) + .count() } -impl PartialEq for DatetimeStyleCustomProps { - fn eq(&self, other: &Self) -> bool { - self.enable_time_config == other.enable_time_config && self.config == other.config +fn part_field( + props: &DatetimeStyleCustomProps, + label: &'static str, + get: fn(&DatetimeFormat) -> Option, + set: fn(&mut DatetimeFormat, Option), +) -> Html +where + T: Copy + Display + PartialEq + IntoEnumIterator + 'static, +{ + let hideable = get(&props.shape).is_none() || shown_parts(&props.shape) > 1; + let on_change = props.on_change.reform(move |choice| -> ShapeEdit { + Rc::new(move |shape: &DatetimeFormat, reset: &DatetimeFormat| { + let mut next = shape.clone(); + set(&mut next, PartChoice::resolve(choice, get(reset))); + next + }) + }); + + html! { + > + {label} + values={part_choices::(hideable)} + default_value={PartChoice::from(get(&props.default_shape))} + current_value={PartChoice::from(get(&props.shape))} + {on_change} + /> } } -pub enum DatetimeStyleCustomMsg { - FractionalSeconds(Option), - Year(CustomDatetimeFormat), - Month(CustomDatetimeFormat), - Day(CustomDatetimeFormat), - Weekday(CustomDatetimeFormat), - Hour(CustomDatetimeFormat), - Minute(CustomDatetimeFormat), - Second(CustomDatetimeFormat), - Hour12(bool), -} - -/// The custom variation of the options parameter for `Intl.DatetimeFormat`. -/// Complement to `DatetimeStyleSimple`. -pub struct DatetimeStyleCustom { - config: CustomDatetimeStyleConfig, -} - -impl Component for DatetimeStyleCustom { - type Message = DatetimeStyleCustomMsg; - type Properties = DatetimeStyleCustomProps; - - fn create(ctx: &Context) -> Self { - ctx.set_modal_link(); - Self { - config: ctx.props().config.clone(), - } +/// The per-part `Intl.DateTimeFormat` editor, the alternative to +/// [`super::simple::DatetimeStyleSimple`]. +#[function_component(DatetimeStyleCustom)] +pub fn datetime_style_custom(props: &DatetimeStyleCustomProps) -> Html { + if !props.enable_time_config { + return html! {}; } - // Always re-render when config changes. - fn changed(&mut self, ctx: &Context, _old: &Self::Properties) -> bool { - let mut new_config = ctx.props().config.clone(); - if self.config != new_config { - std::mem::swap(&mut self.config, &mut new_config); - true - } else { - false - } - } - - // TODO could be more conservative here with re-rendering - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - DatetimeStyleCustomMsg::Year(year) => { - self.config.year = year; - self.dispatch_config(ctx); - true - }, - DatetimeStyleCustomMsg::Month(month) => { - self.config.month = month; - self.dispatch_config(ctx); - true - }, - DatetimeStyleCustomMsg::Day(day) => { - self.config.day = day; - self.dispatch_config(ctx); - true - }, - DatetimeStyleCustomMsg::Weekday(weekday) => { - self.config.weekday = weekday; - self.dispatch_config(ctx); - true + let on_fraction = props.on_change.reform(|digits: Option| -> ShapeEdit { + Rc::new( + move |shape: &DatetimeFormat, reset: &DatetimeFormat| DatetimeFormat { + fractional_second_digits: digits.map_or(reset.fractional_second_digits, |x| { + FractionalSecondDigits::new(x as u8) + }), + ..shape.clone() }, - DatetimeStyleCustomMsg::Hour(hour) => { - self.config.hour = hour; - self.dispatch_config(ctx); - true - }, - DatetimeStyleCustomMsg::Minute(minute) => { - self.config.minute = minute; - self.dispatch_config(ctx); - true - }, - DatetimeStyleCustomMsg::Second(second) => { - self.config.second = second; - self.dispatch_config(ctx); - true - }, - DatetimeStyleCustomMsg::FractionalSeconds(fractional) => { - self.config.fractional_seconds = fractional.unwrap_or_default().floor() as u32; - self.dispatch_config(ctx); - true - }, - DatetimeStyleCustomMsg::Hour12(hour12) => { - self.config.hour12 = hour12; - self.dispatch_config(ctx); - true - }, - } - } - - fn view(&self, ctx: &Context) -> Html { - let all_values = Rc::new(Vec::from(CustomDatetimeFormat::values())); - let number_values = Rc::new(Vec::from(CustomDatetimeFormat::values_1())); - let text_values = Rc::new(Vec::from(CustomDatetimeFormat::values_2())); - type SelectStrField = SelectValueField<&'static str>; - html! { - <> - if ctx.props().enable_time_config { - - label="year" - values={number_values.clone()} - default_value={CustomDatetimeFormat::TwoDigit} - on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Year(x.unwrap_or(CustomDatetimeFormat::TwoDigit)))} - current_value={self.config.year} - /> - - label="month" - values={all_values.clone()} - default_value={CustomDatetimeFormat::Numeric} - on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Month(x.unwrap_or(CustomDatetimeFormat::Numeric)))} - current_value={self.config.month} - /> - - label="day" - values={number_values.clone()} - default_value={CustomDatetimeFormat::Numeric} - on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Day(x.unwrap_or(CustomDatetimeFormat::Numeric)))} - current_value={self.config.day} - /> - - label="weekday" - values={text_values.clone()} - default_value={CustomDatetimeFormat::Disabled} - on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Weekday(x.unwrap_or(CustomDatetimeFormat::Disabled)))} - current_value={self.config.weekday} - /> - - label="hour" - values={number_values.clone()} - default_value={CustomDatetimeFormat::Numeric} - on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Hour(x.unwrap_or(CustomDatetimeFormat::Numeric)))} - current_value={self.config.hour} - /> - - label="minute" - values={number_values.clone()} - default_value={CustomDatetimeFormat::Numeric} - on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Minute(x.unwrap_or(CustomDatetimeFormat::Numeric)))} - current_value={self.config.minute} - /> - - label="second" - values={number_values.clone()} - default_value={CustomDatetimeFormat::Numeric} - on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Second(x.unwrap_or(CustomDatetimeFormat::Numeric)))} - current_value={self.config.second} - /> - - | DatetimeStyleCustomMsg::Hour12(x != Some("24 Hour") ))} - current_value={if self.config.hour12 { "12 Hour" } else { "24 Hour" }} - /> - } - - } - } -} - -impl DatetimeStyleCustom { - fn dispatch_config(&self, ctx: &Context) { - ctx.props().on_change.emit(self.config.clone()); + ) + }); + + let fraction = |format: &DatetimeFormat| { + format + .fractional_second_digits + .map(|x| x.get() as f64) + .unwrap_or(0.) + }; + + html! { + <> + { part_field::(props, "year", |f| f.year, |f, x| f.year = x) } + { part_field::(props, "month", |f| f.month, |f, x| f.month = x) } + { part_field::(props, "day", |f| f.day, |f, x| f.day = x) } + { part_field::(props, "weekday", |f| f.weekday, |f, x| f.weekday = x) } + { part_field::(props, "hour", |f| f.hour, |f, x| f.hour = x) } + { part_field::(props, "minute", |f| f.minute, |f, x| f.minute = x) } + { part_field::(props, "second", |f| f.second, |f, x| f.second = x) } + + } } diff --git a/rust/perspective-viewer/src/rust/components/datetime_column_style/simple.rs b/rust/perspective-viewer/src/rust/components/datetime_column_style/simple.rs index 8289ab98de..691635f647 100644 --- a/rust/perspective-viewer/src/rust/components/datetime_column_style/simple.rs +++ b/rust/perspective-viewer/src/rust/components/datetime_column_style/simple.rs @@ -10,122 +10,72 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +use std::rc::Rc; + use yew::prelude::*; +use super::{PartChoice, ShapeEdit, part_choices}; use crate::config::*; -use crate::ui::{ModalLink, SelectEnumField, SetModalLink}; -use crate::utils::WeakScope; +use crate::ui::SelectValueField; -#[derive(Properties)] +#[derive(Properties, PartialEq)] pub struct DatetimeStyleSimpleProps { pub enable_time_config: bool, - pub config: SimpleDatetimeStyleConfig, - - /// The plugin-declared default preset for each style select. - #[prop_or(SimpleDatetimeFormat::Short)] - pub date_style_default: SimpleDatetimeFormat, - - #[prop_or(SimpleDatetimeFormat::Medium)] - pub time_style_default: SimpleDatetimeFormat, - - #[prop_or_default] - pub on_change: Callback, - - #[prop_or_default] - weak_link: WeakScope, -} -impl ModalLink for DatetimeStyleSimpleProps { - fn weak_link(&self) -> &'_ WeakScope { - &self.weak_link - } -} - -impl PartialEq for DatetimeStyleSimpleProps { - fn eq(&self, other: &Self) -> bool { - self.enable_time_config == other.enable_time_config - && self.config == other.config - && self.date_style_default == other.date_style_default - && self.time_style_default == other.time_style_default - } -} + /// The preset shown. + pub shape: DatetimeFormat, -pub enum DatetimeStyleSimpleMsg { - DateStyleChanged(Option), - TimeStyleChanged(Option), -} + /// The preset a field's reset restores. + pub default_shape: DatetimeFormat, -/// `DatetimeStyleSimple` represents the variation of the options parameter to -/// `Intl.DatetimeFormat()` which supports `timeStyle` and `dateStyle`. These -/// options are mutually exclusive with those of `DatetimeStyleCustom`, hence -/// the two-struct model for this options parameter. -pub struct DatetimeStyleSimple { - config: SimpleDatetimeStyleConfig, + pub on_change: Callback, } -impl Component for DatetimeStyleSimple { - type Message = DatetimeStyleSimpleMsg; - type Properties = DatetimeStyleSimpleProps; - - fn create(ctx: &Context) -> Self { - ctx.set_modal_link(); - Self { - config: ctx.props().config.clone(), - } - } - - // TODO could be more conservative here with re-rendering - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - DatetimeStyleSimpleMsg::DateStyleChanged(format) => { - self.config.date_style = format.unwrap_or(ctx.props().date_style_default); - self.dispatch_config(ctx); - true +/// The `dateStyle`/`timeStyle` editor. +#[function_component(DatetimeStyleSimple)] +pub fn datetime_style_simple(props: &DatetimeStyleSimpleProps) -> Html { + type PresetField = SelectValueField>; + let date_values = part_choices::( + props.enable_time_config && props.shape.time_style.is_some(), + ); + + let time_values = part_choices::(props.shape.date_style.is_some()); + let on_date = props.on_change.reform(|choice| -> ShapeEdit { + Rc::new( + move |shape: &DatetimeFormat, reset: &DatetimeFormat| DatetimeFormat { + date_style: PartChoice::resolve(choice, reset.date_style), + ..shape.clone() }, - DatetimeStyleSimpleMsg::TimeStyleChanged(format) => { - self.config.time_style = format.unwrap_or(ctx.props().time_style_default); - self.dispatch_config(ctx); - true + ) + }); + + let on_time = props.on_change.reform(|choice| -> ShapeEdit { + Rc::new( + move |shape: &DatetimeFormat, reset: &DatetimeFormat| DatetimeFormat { + time_style: PartChoice::resolve(choice, reset.time_style), + ..shape.clone() }, - } - } - - // Always re-render when config changes. - fn changed(&mut self, ctx: &Context, _old: &Self::Properties) -> bool { - let mut new_config = ctx.props().config.clone(); - if self.config != new_config { - std::mem::swap(&mut self.config, &mut new_config); - true - } else { - false - } - } - - fn view(&self, ctx: &Context) -> Html { - html! { - <> - - label="date-style" - on_change={ctx.link().callback(DatetimeStyleSimpleMsg::DateStyleChanged)} - current_value={self.config.date_style} - default_value={ctx.props().date_style_default} + ) + }); + + html! { + <> + + if props.enable_time_config { + - if ctx.props().enable_time_config { - - label="time-style" - on_change={ctx.link().callback(DatetimeStyleSimpleMsg::TimeStyleChanged)} - current_value={self.config.time_style} - default_value={ctx.props().time_style_default} - /> - } - - } - } -} - -impl DatetimeStyleSimple { - /// When this config has changed, we must signal the wrapper element. - fn dispatch_config(&self, ctx: &Context) { - ctx.props().on_change.emit(self.config.clone()); + } + } } diff --git a/rust/perspective-viewer/src/rust/components/plugin_tab.rs b/rust/perspective-viewer/src/rust/components/plugin_tab.rs index b7683638f8..0f7ac8eca1 100644 --- a/rust/perspective-viewer/src/rust/components/plugin_tab.rs +++ b/rust/perspective-viewer/src/rust/components/plugin_tab.rs @@ -27,7 +27,7 @@ use crate::components::column_settings_sidebar::style_tab::primitive_field::{ use crate::config::{Alignment, ControlSpec, FontToggle}; use crate::presentation::Presentation; use crate::queries::get_plugin_config_schema; -use crate::renderer::Renderer; +use crate::renderer::{ConfigDefaultsSlots, Renderer}; use crate::session::Session; use crate::tasks::send_plugin_config; use crate::ui::ControlGroup; @@ -50,6 +50,9 @@ pub struct PluginTabProps { /// interior-mutable handle. pub plugin_config: PtrEqRc>, + /// The page-wide config defaults the schema applies. + pub config_defaults: PtrEqRc, + // State pub presentation: Presentation, pub renderer: Renderer, @@ -76,7 +79,11 @@ pub fn PluginTab(props: &PluginTabProps) -> Html { let view_config = props.view_config.clone(); let plugin_config = props.plugin_config.clone(); use_memo( - (props.plugin_config.clone(), props.view_config.clone()), + ( + props.plugin_config.clone(), + props.view_config.clone(), + props.config_defaults.clone(), + ), move |_| match get_plugin_config_schema(&renderer, &view_config, Some(&plugin_config)) { Ok(schema) => schema.fields, Err(error) => { diff --git a/rust/perspective-viewer/src/rust/components/settings_panel.rs b/rust/perspective-viewer/src/rust/components/settings_panel.rs index dba4cade3c..c4269535e5 100644 --- a/rust/perspective-viewer/src/rust/components/settings_panel.rs +++ b/rust/perspective-viewer/src/rust/components/settings_panel.rs @@ -64,6 +64,9 @@ pub struct SettingsPanelProps { /// from `RendererProps` into `ColumnSelector`. pub columns_config: PtrEqRc, + /// The page-wide config defaults, forwarded into `PluginTab`. + pub config_defaults: PtrEqRc, + /// Column currently being dragged (if any) — threaded to show drag /// highlights without per-component `DragDrop` PubSub subscriptions. pub drag_column: Option, @@ -119,6 +122,7 @@ impl PartialEq for SettingsPanelProps { && self.view_config == rhs.view_config && self.plugin_config == rhs.plugin_config && self.columns_config == rhs.columns_config + && self.config_defaults == rhs.config_defaults && self.drag_column == rhs.drag_column && self.metadata == rhs.metadata && self.open_column_settings == rhs.open_column_settings @@ -376,6 +380,7 @@ pub fn SettingsPanel(props: &SettingsPanelProps) -> Html { , + default: Option, }, NumberSeriesStyle { default: NumberSeriesStyleDefaultConfig, @@ -500,6 +501,279 @@ impl ControlSpec { } } +/// Default overrides resolved for one schema: the value each overridden key +/// resolves to across the override layers alone, keyed by serialized key. +pub type OverrideValues = Map; + +impl ColumnConfigSchema { + /// Substitute the values `layers` resolve to into this schema's defaults, + /// returning the override-only value of every key applied and the rejected + /// `(key, error)` pairs. + pub fn apply_defaults( + &mut self, + layers: &[&Map], + is_float: bool, + ) -> (OverrideValues, Vec<(String, String)>) { + let (resolved, mut errors) = resolve_layers(layers); + let mut applied = OverrideValues::new(); + fn walk( + fields: &mut [ControlSpec], + resolved: &OverrideValues, + is_float: bool, + applied: &mut OverrideValues, + errors: &mut Vec<(String, String)>, + ) { + for spec in fields { + if let ControlSpec::Group { fields, .. } = spec { + walk(fields, resolved, is_float, applied, errors); + continue; + } + + let keys: Vec = spec + .serialized_keys() + .into_iter() + .map(str::to_owned) + .collect(); + + for key in keys { + let Some(value) = resolved.get(&key) else { + continue; + }; + + match spec.apply_default(&key, value, is_float) { + Ok(Some(delivered)) => { + applied.insert(key, delivered); + }, + Ok(None) => {}, + Err(error) => errors.push((key, error)), + } + } + } + } + + walk( + &mut self.fields, + &resolved, + is_float, + &mut applied, + &mut errors, + ); + + (applied, errors) + } +} + +/// The value each key resolves to across `layers`, lowest first: the highest +/// layer's value for plain keys, and the layered fold for `date_format` and +/// `number_format`. +fn resolve_layers(layers: &[&Map]) -> (OverrideValues, Vec<(String, String)>) { + let mut resolved = OverrideValues::new(); + let mut errors = vec![]; + let mut date_format: Option = None; + let mut number_format: Option = None; + for layer in layers { + for (key, value) in layer.iter() { + match key.as_str() { + "date_format" => match serde_json::from_value::(value.clone()) + .map_err(|e| e.to_string()) + .and_then(|f| f.validate().map(|_| f)) + { + Ok(format) => { + date_format = Some(date_format.unwrap_or_default().overlay(&format)); + }, + Err(error) => errors.push((key.clone(), error)), + }, + "number_format" => { + match serde_json::from_value::(value.clone()) { + Ok(format) => { + number_format = + Some(number_format.unwrap_or_default().overlay(&format)); + }, + Err(error) => errors.push((key.clone(), error.to_string())), + } + }, + _ => { + resolved.insert(key.clone(), value.clone()); + }, + } + } + } + + if let Some(format) = date_format.and_then(|f| serde_json::to_value(f).ok()) { + resolved.insert("date_format".to_owned(), format); + } + + if let Some(format) = number_format.and_then(|f| serde_json::to_value(f).ok()) { + resolved.insert("number_format".to_owned(), format); + } + + (resolved, errors) +} + +fn expect_str(value: &Value) -> Result<&str, String> { + value + .as_str() + .ok_or_else(|| format!("expected a string, got `{value}`")) +} + +fn expect_bool(value: &Value) -> Result { + value + .as_bool() + .ok_or_else(|| format!("expected a boolean, got `{value}`")) +} + +fn expect_number(value: &Value, min: Option, max: Option) -> Result { + let num = value + .as_f64() + .ok_or_else(|| format!("expected a number, got `{value}`"))?; + + if min.is_some_and(|min| num < min) || max.is_some_and(|max| num > max) { + return Err(format!( + "{num} is outside {}..={}", + min.map(|x| x.to_string()).unwrap_or_default(), + max.map(|x| x.to_string()).unwrap_or_default() + )); + } + + Ok(num) +} + +impl ControlSpec { + /// Substitute the resolved default override `value` for `key` into this + /// control, returning the value to deliver for it, or `None` when it does + /// not own `key`. + pub fn apply_default( + &mut self, + key: &str, + value: &Value, + is_float: bool, + ) -> Result, String> { + match self { + ControlSpec::Enum { + key: k, + variants, + default, + } if k == key => { + let value = expect_str(value)?; + if !variants.iter().any(|v| v.value == value) { + let expected = variants + .iter() + .map(|v| format!("`{}`", v.value)) + .collect::>() + .join(", "); + + return Err(format!("`{value}` is not one of {expected}")); + } + + *default = value.to_owned(); + }, + ControlSpec::Font { + key: k, default, .. + } if k == key => { + *default = expect_str(value)?.to_owned(); + }, + ControlSpec::Font { + size: Some(size), .. + } if size.key == key => { + size.default = expect_number(value, size.min, size.max)?; + }, + ControlSpec::Font { bold, italic, .. } + if [&*bold, &*italic] + .into_iter() + .flatten() + .any(|toggle| toggle.key == key) => + { + let value = expect_bool(value)?; + for toggle in [bold, italic].into_iter().flatten() { + if toggle.key == key { + toggle.default = value; + } + } + }, + ControlSpec::Alignment { + key: k, + default, + corners, + } if k == key => { + let src = expect_str(value)?; + let alignment = + Alignment::parse(src).ok_or_else(|| format!("`{src}` is not an alignment"))?; + + if *corners && !alignment.is_corner() { + return Err(format!("`{src}` is not a corner")); + } + + *default = Some(alignment); + }, + ControlSpec::Bool { key: k, default } if k == key => { + *default = expect_bool(value)?; + }, + ControlSpec::Number { + key: k, + default, + min, + max, + default_stat, + .. + } if k == key => { + *default = expect_number(value, *min, *max)?; + *default_stat = None; + }, + ControlSpec::String { + key: k, default, .. + } if k == key => { + *default = expect_str(value)?.to_owned(); + }, + ControlSpec::Color { key: k, default } if k == key => { + *default = CssKind::Color.canonicalize(expect_str(value)?)?; + return Ok(Some(Value::String(default.clone()))); + }, + ControlSpec::Palette { + key: k, default, .. + } if k == key => { + *default = CssKind::Palette.canonicalize(expect_str(value)?)?; + return Ok(Some(Value::String(default.clone()))); + }, + ControlSpec::GradientStops { + key: k, default, .. + } if k == key => { + *default = CssKind::Gradient.canonicalize(expect_str(value)?)?; + return Ok(Some(Value::String(default.clone()))); + }, + ControlSpec::DatetimeFormat { default } if key == "date_format" => { + let format: DatetimeFormat = + serde_json::from_value(value.clone()).map_err(|e| e.to_string())?; + + *default = Some(default.clone().unwrap_or_default().overlay(&format)); + }, + ControlSpec::NumberFormat { default } if key == "number_format" => { + let format: CustomNumberFormatConfig = + serde_json::from_value(value.clone()).map_err(|e| e.to_string())?; + + let resolved = default.clone().unwrap_or_default().overlay(&format); + resolved.validate(is_float)?; + *default = Some(resolved); + }, + ControlSpec::NumberSeriesStyle { default } if key == "chart_type" => { + default.chart_type = serde_json::from_value::(value.clone()) + .map_err(|e| e.to_string())?; + }, + ControlSpec::NumberSeriesStyle { default } if key == "stack" => { + default.stack = Some(expect_bool(value)?); + }, + ControlSpec::Symbols { .. } if key == "symbols" => { + return Err("`symbols` has no overridable default".to_owned()); + }, + ControlSpec::AggregateDepth if key == "aggregate_depth" => { + return Err("`aggregate_depth` has no overridable default".to_owned()); + }, + _ => return Ok(None), + } + + Ok(Some(value.clone())) + } +} + /// One UI-emitted change to a single schema field. The emitting widget /// declares which top-level keys the update is allowed to write /// (`keys` — equivalent to the field's [`ControlSpec::serialized_keys`]) diff --git a/rust/perspective-viewer/src/rust/config/datetime_column_style.rs b/rust/perspective-viewer/src/rust/config/datetime_column_style.rs index 2203fb0eff..b477db0e65 100644 --- a/rust/perspective-viewer/src/rust/config/datetime_column_style.rs +++ b/rust/perspective-viewer/src/rust/config/datetime_column_style.rs @@ -10,57 +10,203 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -mod custom; -mod custom_format; -mod simple; +mod migrate; +mod part_format; mod simple_format; -pub use custom::*; -pub use custom_format::*; +pub use migrate::*; +pub use part_format::*; use serde::{Deserialize, Serialize}; -pub use simple::*; pub use simple_format::*; use ts_rs::TS; -/// `Simple` case has all default-able keys and must be last! -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] -// #[serde(tag = "format", content = "date_format")] -#[serde(untagged)] -pub enum DatetimeFormatType { - // #[serde(rename = "custom")] - Custom(CustomDatetimeStyleConfig), - Simple(SimpleDatetimeStyleConfig), +/// A datetime column's `date_format`, a subset of `Intl.DateTimeFormatOptions` +/// whose display fields inherit as one group and whose `timeZone` and `hour12` +/// inherit individually. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DatetimeFormat { + /// An IANA time zone name such as `"America/New_York"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub time_zone: Option, + + /// Whether hours use a 12-hour clock. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub hour12: Option, + + /// The date preset, which excludes every per-part field. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub date_style: Option, + + /// The time preset, which excludes every per-part field. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub time_style: Option, + + /// The weekday's form. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub weekday: Option, + + /// The year's form. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub year: Option, + + /// The month's form. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub month: Option, + + /// The day's form. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub day: Option, + + /// The hour's form. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub hour: Option, + + /// The minute's form. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub minute: Option, + + /// The second's form. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub second: Option, + + /// Sub-second digits shown. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub fractional_second_digits: Option, } -impl Default for DatetimeFormatType { - fn default() -> Self { - Self::Simple(SimpleDatetimeStyleConfig::default()) +impl DatetimeFormat { + /// The viewer's own default, below every plugin and override layer. + pub fn base(with_time: bool) -> Self { + Self { + date_style: Some(SimpleDatetimeFormat::Short), + time_style: with_time.then_some(SimpleDatetimeFormat::Medium), + ..Self::default() + } } -} -impl DatetimeFormatType { - pub fn time_zone(&self) -> &Option { - match self { - DatetimeFormatType::Custom(x) => &x.time_zone, - DatetimeFormatType::Simple(x) => &x.time_zone, + /// The per-part shape a switch from preset to per-part display starts from. + pub fn parts_base(with_time: bool) -> Self { + let time = with_time.then_some(DatetimeNumericPart::Numeric); + Self { + year: Some(DatetimeNumericPart::TwoDigit), + month: Some(DatetimeMonthPart::Numeric), + day: Some(DatetimeNumericPart::Numeric), + hour: time, + minute: time, + second: time, + ..Self::default() + } + } + + /// Whether this format sets `dateStyle` or `timeStyle`. + pub fn has_preset(&self) -> bool { + self.date_style.is_some() || self.time_style.is_some() + } + + /// Whether this format sets any per-part display field. + pub fn has_parts(&self) -> bool { + self.weekday.is_some() + || self.year.is_some() + || self.month.is_some() + || self.day.is_some() + || self.hour.is_some() + || self.minute.is_some() + || self.second.is_some() + || self.fractional_second_digits.is_some() + } + + /// Whether this format sets any display field. + pub fn has_shape(&self) -> bool { + self.has_preset() || self.has_parts() + } + + /// Whether this format sets nothing. + pub fn is_empty(&self) -> bool { + *self == Self::default() + } + + /// This format's display fields alone. + pub fn shape(&self) -> Self { + Self { + time_zone: None, + hour12: None, + ..self.clone() + } + } + + /// This format with its display fields replaced by `shape`'s. + pub fn with_shape(&self, shape: &Self) -> Self { + Self { + time_zone: self.time_zone.clone(), + hour12: self.hour12, + ..shape.shape() + } + } + + /// `upper` layered over this format: the display fields of whichever sets + /// any, `upper` first, and each of `timeZone` and `hour12` from whichever + /// sets it. + pub fn overlay(&self, upper: &Self) -> Self { + let shape = if upper.has_shape() { upper } else { self }; + Self { + time_zone: upper.time_zone.clone().or_else(|| self.time_zone.clone()), + hour12: upper.hour12.or(self.hour12), + ..shape.shape() + } + } + + /// The fields of this format that differ from `inherited`, keeping or + /// dropping the display fields as one group. + pub fn diff(&self, inherited: &Self) -> Self { + let shape = if self.has_shape() && self.shape() != inherited.shape() { + self.shape() + } else { + Self::default() + }; + + Self { + time_zone: self + .time_zone + .clone() + .filter(|tz| Some(tz) != inherited.time_zone.as_ref()), + hour12: self.hour12.filter(|h| Some(*h) != inherited.hour12), + ..shape } } - pub fn time_zone_mut(&mut self) -> &mut Option { - match self { - DatetimeFormatType::Custom(x) => &mut x.time_zone, - DatetimeFormatType::Simple(x) => &mut x.time_zone, + /// `Err` when this format sets both preset and per-part display fields, + /// which `Intl` rejects. + pub fn validate(&self) -> Result<(), String> { + if self.has_preset() && self.has_parts() { + Err( + "`dateStyle`/`timeStyle` cannot be combined with `weekday`, `year`, `month`, \ + `day`, `hour`, `minute`, `second` or `fractionalSecondDigits`" + .to_owned(), + ) + } else { + Ok(()) } } } -/// A model for the JSON serialized style configuration for a column of type -/// `datetime`. +/// The `date_format` key of a datetime column's config entry. #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, TS)] -// #[derive(WasmDescribe!, FromWasmAbi!)] pub struct DatetimeColumnStyleConfig { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option<_>")] - pub date_format: Option, + pub date_format: Option, } diff --git a/rust/perspective-viewer/src/rust/config/datetime_column_style/custom.rs b/rust/perspective-viewer/src/rust/config/datetime_column_style/custom.rs deleted file mode 100644 index 2ae0daf1da..0000000000 --- a/rust/perspective-viewer/src/rust/config/datetime_column_style/custom.rs +++ /dev/null @@ -1,138 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -use super::custom_format::CustomDatetimeFormat; - -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, TS)] -pub enum FormatUnit { - #[serde(rename = "custom")] - #[default] - FormatUnit, -} - -const fn is_zero(x: &u32) -> bool { - *x == 0 -} - -const fn is_true(x: &bool) -> bool { - *x -} - -const fn is_disabled(x: &CustomDatetimeFormat) -> bool { - matches!(x, CustomDatetimeFormat::Disabled) -} - -const fn is_numeric(x: &CustomDatetimeFormat) -> bool { - matches!(x, CustomDatetimeFormat::Numeric) -} - -const fn is_two_digit(x: &CustomDatetimeFormat) -> bool { - matches!(x, CustomDatetimeFormat::TwoDigit) -} - -const fn second_default() -> CustomDatetimeFormat { - CustomDatetimeFormat::Numeric -} - -const fn weekday_default() -> CustomDatetimeFormat { - CustomDatetimeFormat::Disabled -} - -const fn year_default() -> CustomDatetimeFormat { - CustomDatetimeFormat::TwoDigit -} - -const fn hour12_default() -> bool { - true -} - -const fn numeric_default() -> u32 { - 0 -} - -/// A datetime column's `date_format` in its `Custom` form: per-part -/// `Intl.DateTimeFormatOptions` overrides, discriminated from the `Simple` -/// preset form by the required `format: "custom"` key. Each part is a -/// [`CustomDatetimeFormat`] variant valid for that `Intl` option; -/// `"disabled"` omits the part from the formatted output entirely. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] -pub struct CustomDatetimeStyleConfig { - format: FormatUnit, - - /// An IANA time zone name (e.g. `"America/New_York"`); defaults to the - /// browser's local time zone. - #[serde(default)] - #[serde(rename = "timeZone", skip_serializing_if = "Option::is_none")] - pub time_zone: Option, - - /// Sub-second digits to display, 0-3 (0 = none). - #[serde( - skip_serializing_if = "is_zero", - rename = "fractionalSecondDigits", - default = "numeric_default" - )] - pub fractional_seconds: u32, - - /// Defaults to `"numeric"`. - #[serde(skip_serializing_if = "is_numeric", default = "second_default")] - pub second: CustomDatetimeFormat, - - /// Defaults to `"numeric"`. - #[serde(skip_serializing_if = "is_numeric", default = "second_default")] - pub minute: CustomDatetimeFormat, - - /// Defaults to `"numeric"`. - #[serde(skip_serializing_if = "is_numeric", default = "second_default")] - pub hour: CustomDatetimeFormat, - - /// Defaults to `"numeric"`. - #[serde(skip_serializing_if = "is_numeric", default = "second_default")] - pub day: CustomDatetimeFormat, - - /// A name form (`"long"`/`"short"`/`"narrow"`); defaults to - /// `"disabled"` (weekday not shown). - #[serde(skip_serializing_if = "is_disabled", default = "weekday_default")] - pub weekday: CustomDatetimeFormat, - - /// Numeric or name form; defaults to `"numeric"`. - #[serde(skip_serializing_if = "is_numeric", default = "second_default")] - pub month: CustomDatetimeFormat, - - /// Defaults to `"2-digit"`. - #[serde(skip_serializing_if = "is_two_digit", default = "year_default")] - pub year: CustomDatetimeFormat, - - /// 12-hour clock; defaults to `true`. - #[serde(skip_serializing_if = "is_true", default = "hour12_default")] - pub hour12: bool, -} - -impl Default for CustomDatetimeStyleConfig { - fn default() -> Self { - CustomDatetimeStyleConfig { - format: FormatUnit::FormatUnit, - fractional_seconds: 0, - time_zone: None, - second: CustomDatetimeFormat::Numeric, - minute: CustomDatetimeFormat::Numeric, - hour: CustomDatetimeFormat::Numeric, - weekday: CustomDatetimeFormat::Disabled, - day: CustomDatetimeFormat::Numeric, - month: CustomDatetimeFormat::Numeric, - year: CustomDatetimeFormat::TwoDigit, - hour12: true, - } - } -} diff --git a/rust/perspective-viewer/src/rust/config/datetime_column_style/migrate.rs b/rust/perspective-viewer/src/rust/config/datetime_column_style/migrate.rs new file mode 100644 index 0000000000..abc570f292 --- /dev/null +++ b/rust/perspective-viewer/src/rust/config/datetime_column_style/migrate.rs @@ -0,0 +1,95 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use serde_json::{Map, Value}; + +use crate::config::API_VERSION; +use crate::renderer::ColumnConfigMap; + +/// The first release whose stored `date_format` fields inherit when missing. +const INHERITING_RELEASE: (u64, u64, u64) = (5, 7, 0); + +const PART_FALLBACKS: [(&str, &str); 6] = [ + ("year", "2-digit"), + ("month", "numeric"), + ("day", "numeric"), + ("hour", "numeric"), + ("minute", "numeric"), + ("second", "numeric"), +]; + +const SHAPE_KEYS: [&str; 10] = [ + "dateStyle", + "timeStyle", + "weekday", + "year", + "month", + "day", + "hour", + "minute", + "second", + "fractionalSecondDigits", +]; + +/// Rewrite every `date_format` in `columns` written before 5.7 into the form +/// that renders identically under inheriting semantics. +pub fn migrate_date_formats(version: Option<&str>, columns: &mut ColumnConfigMap) { + let versioned_legacy = version.is_some_and(is_legacy_version); + for entry in columns.values_mut() { + if let Some(Value::Object(format)) = entry.get_mut("date_format") { + migrate_date_format(format, versioned_legacy); + } + } +} + +fn is_legacy_version(version: &str) -> bool { + version != *API_VERSION && parse_version(version).is_some_and(|v| v < INHERITING_RELEASE) +} + +fn parse_version(version: &str) -> Option<(u64, u64, u64)> { + let core = version.split(['-', '+']).next()?; + let mut parts = core.split('.').map(|x| x.parse::().ok()); + Some((parts.next()??, parts.next()??, parts.next()??)) +} + +fn is_disabled(value: &Value) -> bool { + value.as_str() == Some("disabled") +} + +fn migrate_date_format(format: &mut Map, versioned_legacy: bool) { + if format.remove("format").is_some() { + for (key, fallback) in PART_FALLBACKS { + format.entry(key).or_insert_with(|| Value::from(fallback)); + } + + format.entry("hour12").or_insert(Value::Bool(true)); + if format.get("fractionalSecondDigits").and_then(Value::as_u64) == Some(0) { + format.remove("fractionalSecondDigits"); + } + } else if versioned_legacy || format.values().any(is_disabled) { + format + .entry("dateStyle") + .or_insert_with(|| Value::from("short")); + format + .entry("timeStyle") + .or_insert_with(|| Value::from("medium")); + } else { + return; + } + + format.retain(|_, value| !is_disabled(value)); + if !SHAPE_KEYS.iter().any(|key| format.contains_key(*key)) { + for key in ["year", "month", "day"] { + format.insert(key.to_owned(), Value::from("numeric")); + } + } +} diff --git a/rust/perspective-viewer/src/rust/config/datetime_column_style/custom_format.rs b/rust/perspective-viewer/src/rust/config/datetime_column_style/part_format.rs similarity index 51% rename from rust/perspective-viewer/src/rust/config/datetime_column_style/custom_format.rs rename to rust/perspective-viewer/src/rust/config/datetime_column_style/part_format.rs index e531afb821..f9ff60c843 100644 --- a/rust/perspective-viewer/src/rust/config/datetime_column_style/custom_format.rs +++ b/rust/perspective-viewer/src/rust/config/datetime_column_style/part_format.rs @@ -11,80 +11,114 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ use std::fmt::Display; -use std::str::FromStr; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use strum::EnumIter; use ts_rs::TS; -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] -pub enum CustomDatetimeFormat { - #[serde(rename = "long")] - Long, +/// A date or time part's numeric form. +#[derive(Clone, Copy, Debug, Deserialize, EnumIter, Eq, PartialEq, Serialize, TS)] +pub enum DatetimeNumericPart { + #[serde(rename = "numeric")] + Numeric, - #[serde(rename = "short")] - Short, + #[serde(rename = "2-digit")] + TwoDigit, +} - #[serde(rename = "narrow")] - Narrow, +impl Display for DatetimeNumericPart { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Numeric => "numeric", + Self::TwoDigit => "2-digit", + }) + } +} +/// The month's numeric or named form. +#[derive(Clone, Copy, Debug, Deserialize, EnumIter, Eq, PartialEq, Serialize, TS)] +pub enum DatetimeMonthPart { #[serde(rename = "numeric")] Numeric, #[serde(rename = "2-digit")] TwoDigit, - #[serde(rename = "disabled")] - Disabled, + #[serde(rename = "long")] + Long, + + #[serde(rename = "short")] + Short, + + #[serde(rename = "narrow")] + Narrow, } -impl CustomDatetimeFormat { - pub const fn values() -> &'static [Self] { - &[ - Self::Long, - Self::Short, - Self::Narrow, - Self::Numeric, - Self::TwoDigit, - Self::Disabled, - ] +impl Display for DatetimeMonthPart { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Numeric => "numeric", + Self::TwoDigit => "2-digit", + Self::Long => "long", + Self::Short => "short", + Self::Narrow => "narrow", + }) } +} - pub fn values_1() -> &'static [Self] { - &[Self::Numeric, Self::TwoDigit, Self::Disabled] - } +/// The weekday's named form. +#[derive(Clone, Copy, Debug, Deserialize, EnumIter, Eq, PartialEq, Serialize, TS)] +pub enum DatetimeTextPart { + #[serde(rename = "long")] + Long, - pub fn values_2() -> &'static [Self] { - &[Self::Long, Self::Narrow, Self::Short, Self::Disabled] - } + #[serde(rename = "short")] + Short, + + #[serde(rename = "narrow")] + Narrow, } -impl Display for CustomDatetimeFormat { +impl Display for DatetimeTextPart { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let text = match self { + f.write_str(match self { Self::Long => "long", Self::Short => "short", Self::Narrow => "narrow", - Self::Numeric => "numeric", - Self::TwoDigit => "2-digit", - Self::Disabled => "disabled", - }; + }) + } +} + +/// The number of sub-second digits shown, from 1 to 3. +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +#[ts(type = "1 | 2 | 3")] +pub struct FractionalSecondDigits(u8); + +impl FractionalSecondDigits { + /// The digit count, or `None` outside 1 to 3. + pub fn new(digits: u8) -> Option { + (1..=3).contains(&digits).then_some(Self(digits)) + } + + /// The digit count. + pub fn get(self) -> u8 { + self.0 + } +} - write!(f, "{text}") +impl Serialize for FractionalSecondDigits { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(self.0) } } -impl FromStr for CustomDatetimeFormat { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "long" => Ok(Self::Long), - "short" => Ok(Self::Short), - "narrow" => Ok(Self::Narrow), - "numeric" => Ok(Self::Numeric), - "2-digit" => Ok(Self::TwoDigit), - "disabled" => Ok(Self::Disabled), - x => Err(format!("Unknown DatetimeFormat::{x}")), - } +impl<'de> Deserialize<'de> for FractionalSecondDigits { + fn deserialize>(deserializer: D) -> Result { + let digits = u8::deserialize(deserializer)?; + Self::new(digits).ok_or_else(|| { + serde::de::Error::custom(format!( + "`fractionalSecondDigits` must be 1, 2 or 3, not {digits}" + )) + }) } } diff --git a/rust/perspective-viewer/src/rust/config/datetime_column_style/simple.rs b/rust/perspective-viewer/src/rust/config/datetime_column_style/simple.rs deleted file mode 100644 index 3b39c30ab6..0000000000 --- a/rust/perspective-viewer/src/rust/config/datetime_column_style/simple.rs +++ /dev/null @@ -1,65 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -use super::simple_format::*; - -const fn date_style_default() -> SimpleDatetimeFormat { - SimpleDatetimeFormat::Short -} - -const fn time_style_default() -> SimpleDatetimeFormat { - SimpleDatetimeFormat::Medium -} - -/// A datetime column's `date_format` in its `Simple` preset form: -/// `Intl.DateTimeFormatOptions`' `dateStyle`/`timeStyle` presets. This is -/// the default form (no `format` key); setting `format: "custom"` selects -/// the per-part `CustomDatetimeStyleConfig` instead. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] -pub struct SimpleDatetimeStyleConfig { - /// An IANA time zone name (e.g. `"America/New_York"`); defaults to the - /// browser's local time zone. - #[serde(default)] - #[serde(rename = "timeZone", skip_serializing_if = "Option::is_none")] - pub time_zone: Option, - - /// Date preset breadth, or `"disabled"` to omit the date entirely; - /// defaults to `"short"`. - #[serde( - default = "date_style_default", - rename = "dateStyle", - skip_serializing_if = "SimpleDatetimeFormat::is_short" - )] - pub date_style: SimpleDatetimeFormat, - - /// Time preset breadth, or `"disabled"` to omit the time entirely; - /// defaults to `"medium"`. - #[serde( - default = "time_style_default", - rename = "timeStyle", - skip_serializing_if = "SimpleDatetimeFormat::is_medium" - )] - pub time_style: SimpleDatetimeFormat, -} - -impl Default for SimpleDatetimeStyleConfig { - fn default() -> Self { - Self { - time_zone: None, - date_style: SimpleDatetimeFormat::Short, - time_style: SimpleDatetimeFormat::Medium, - } - } -} diff --git a/rust/perspective-viewer/src/rust/config/datetime_column_style/simple_format.rs b/rust/perspective-viewer/src/rust/config/datetime_column_style/simple_format.rs index 8389453824..9419c21f19 100644 --- a/rust/perspective-viewer/src/rust/config/datetime_column_style/simple_format.rs +++ b/rust/perspective-viewer/src/rust/config/datetime_column_style/simple_format.rs @@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize}; use strum::EnumIter; use ts_rs::TS; +/// A `dateStyle` or `timeStyle` preset. #[derive(Clone, Copy, Debug, Default, Deserialize, EnumIter, Eq, PartialEq, Serialize, TS)] pub enum SimpleDatetimeFormat { #[serde(rename = "full")] @@ -31,29 +32,6 @@ pub enum SimpleDatetimeFormat { #[default] #[serde(rename = "short")] Short, - - #[serde(rename = "disabled")] - Disabled, -} - -impl SimpleDatetimeFormat { - pub fn is_short(&self) -> bool { - self == &Self::Short - } - - pub fn is_medium(&self) -> bool { - self == &Self::Medium - } - - pub const fn values() -> &'static [Self] { - &[ - Self::Full, - Self::Long, - Self::Medium, - Self::Short, - Self::Disabled, - ] - } } impl Display for SimpleDatetimeFormat { @@ -63,7 +41,6 @@ impl Display for SimpleDatetimeFormat { Self::Long => "long", Self::Medium => "medium", Self::Short => "short", - Self::Disabled => "disabled", }; write!(f, "{text}") @@ -79,7 +56,6 @@ impl FromStr for SimpleDatetimeFormat { "long" => Ok(Self::Long), "medium" => Ok(Self::Medium), "short" => Ok(Self::Short), - "disabled" => Ok(Self::Disabled), x => Err(format!("Unknown DatetimeFormat::{x}")), } } diff --git a/rust/perspective-viewer/src/rust/config/number_string_format.rs b/rust/perspective-viewer/src/rust/config/number_string_format.rs index bd53cf2e64..4904d141ac 100644 --- a/rust/perspective-viewer/src/rust/config/number_string_format.rs +++ b/rust/perspective-viewer/src/rust/config/number_string_format.rs @@ -349,6 +349,52 @@ impl NumberFormatDefaults { } impl CustomNumberFormatConfig { + /// `upper` layered over this format field by field, with `style` and + /// `notation` each taken whole. + pub fn overlay(&self, upper: &Self) -> Self { + Self { + _style: upper._style.clone().or_else(|| self._style.clone()), + minimum_integer_digits: upper.minimum_integer_digits.or(self.minimum_integer_digits), + minimum_fraction_digits: upper + .minimum_fraction_digits + .or(self.minimum_fraction_digits), + maximum_fraction_digits: upper + .maximum_fraction_digits + .or(self.maximum_fraction_digits), + minimum_significant_digits: upper + .minimum_significant_digits + .or(self.minimum_significant_digits), + maximum_significant_digits: upper + .maximum_significant_digits + .or(self.maximum_significant_digits), + rounding_priority: upper.rounding_priority.or(self.rounding_priority), + rounding_increment: upper.rounding_increment.or(self.rounding_increment), + rounding_mode: upper.rounding_mode.or(self.rounding_mode), + trailing_zero_display: upper.trailing_zero_display.or(self.trailing_zero_display), + _notation: upper._notation.clone().or_else(|| self._notation.clone()), + use_grouping: upper.use_grouping.or(self.use_grouping), + sign_display: upper.sign_display.or(self.sign_display), + } + } + + /// `Err` when either digit range this format resolves to under `is_float` + /// has its minimum above its maximum. + pub fn validate(&self, is_float: bool) -> Result<(), String> { + let defaults = NumberFormatDefaults::resolve(Some(self), is_float); + for (name, (min, max)) in [ + ("FractionDigits", defaults.fraction), + ("SignificantDigits", defaults.significant), + ] { + if min > max { + return Err(format!( + "`minimum{name}` {min} exceeds `maximum{name}` {max}; set both bounds" + )); + } + } + + Ok(()) + } + pub fn filter_default(self, defaults: &NumberFormatDefaults) -> Self { let (frac_min, frac_max) = defaults.fraction; let rounding_increment = self.rounding_increment; diff --git a/rust/perspective-viewer/src/rust/config/options.rs b/rust/perspective-viewer/src/rust/config/options.rs index 34e539ec25..52ffebb410 100644 --- a/rust/perspective-viewer/src/rust/config/options.rs +++ b/rust/perspective-viewer/src/rust/config/options.rs @@ -15,6 +15,7 @@ //! re-exported from the crate's `typescript_custom_section`) alongside the //! config types, rather than hand-maintained. +use perspective_client::proto::ColumnType; use serde::Deserialize; use ts_rs::TS; @@ -143,3 +144,24 @@ pub struct SaveWorkspaceOptions { #[ts(optional)] pub full_palette: Option, } + +/// Options for the static `setPluginConfigDefaults()` method. +#[derive(Deserialize, Default, TS)] +pub struct ConfigDefaultsOptions { + /// Limits the defaults to the named plugin. + #[ts(optional)] + pub plugin: Option, +} + +/// Options for the static `setColumnConfigDefaults()` method. +#[derive(Deserialize, Default, TS)] +pub struct ColumnConfigDefaultsOptions { + /// Limits the defaults to the named plugin. + #[ts(optional)] + pub plugin: Option, + + /// Limits the defaults to columns of this view type. + #[serde(rename = "type")] + #[ts(optional, as = "Option")] + pub ty: Option, +} diff --git a/rust/perspective-viewer/src/rust/config/viewer_config.rs b/rust/perspective-viewer/src/rust/config/viewer_config.rs index b6526ebbcf..70bad7e734 100644 --- a/rust/perspective-viewer/src/rust/config/viewer_config.rs +++ b/rust/perspective-viewer/src/rust/config/viewer_config.rs @@ -20,7 +20,7 @@ use serde_json::Value; use ts_rs::TS; use wasm_bindgen::prelude::*; -use crate::config::TableRef; +use crate::config::{TableRef, migrate_date_formats}; use crate::renderer::ColumnConfigMap; /// The state of an entire `custom_elements::PerspectiveViewerElement` component @@ -234,9 +234,17 @@ impl ViewerConfigUpdate { Ok(update.into_serde_ext()?) } - pub fn migrate(&self) -> ApiResult { - // TODO: Call the migrate script from js - Ok(self.clone()) + /// Rewrite the parts of this config written by an older viewer into their + /// current form. + pub fn migrate(&mut self) { + if let OptionalUpdate::Update(columns) = &mut self.columns_config { + let version = match &self.version { + OptionalUpdate::Update(version) => Some(version.as_str()), + _ => None, + }; + + migrate_date_formats(version, columns); + } } } @@ -301,6 +309,14 @@ impl ViewerConfigInitial { Ok(config.into_serde_ext()?) } + /// Rewrite the parts of this config written by an older viewer into their + /// current form. + pub fn migrate(&mut self) { + if let Some(columns) = &mut self.columns_config { + migrate_date_formats(self.version.as_deref(), columns); + } + } + /// A default-config panel bound to `table` (the "New panel" menus). /// Exhaustive on purpose — same drift alarm as the `From` impl below. pub fn new(table: impl Into) -> Self { diff --git a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs index 0ddff0a5ae..daf1a8c4f7 100644 --- a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs +++ b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs @@ -178,8 +178,16 @@ pub struct PerspectiveViewerElement { impl CustomElementMetadata for PerspectiveViewerElement { const CUSTOM_ELEMENT_NAME: &'static str = "perspective-viewer"; - const STATICS: &'static [&'static str] = - ["registerPlugin", "get_wasm_module", "get_worker_url"].as_slice(); + const STATICS: &'static [&'static str] = [ + "registerPlugin", + "get_wasm_module", + "get_worker_url", + "setPluginConfigDefaults", + "setColumnConfigDefaults", + "getPluginConfigDefaults", + "getColumnConfigDefaults", + ] + .as_slice(); } impl PerspectiveViewerElement { @@ -1100,6 +1108,7 @@ impl PerspectiveViewerElement { let _effect = effect; let id = name.map(PanelId::from); let mut update = ViewerConfigUpdate::decode(&update)?; + update.migrate(); let settings = std::mem::replace(&mut update.settings, OptionalUpdate::Missing); enum Target { Existing { panel: Panel, active: bool }, @@ -2018,7 +2027,8 @@ impl PerspectiveViewerElement { ApiFuture::new(async move { let _effect = effect; let AddPanelOptions { wait_for_table } = parse_options(options)?; - let config = ViewerConfigInitial::decode(&config)?; + let mut config = ViewerConfigInitial::decode(&config)?; + config.migrate(); let id = create_panel( &elem, &presentation, diff --git a/rust/perspective-viewer/src/rust/lib.rs b/rust/perspective-viewer/src/rust/lib.rs index 8816543096..7ee0b6cd48 100644 --- a/rust/perspective-viewer/src/rust/lib.rs +++ b/rust/perspective-viewer/src/rust/lib.rs @@ -108,7 +108,9 @@ export type * from "../../src/ts/ts-rs/GetViewMode.d.ts"; export type * from "../../src/ts/ts-rs/CustomNumberFormatConfig.d.ts"; export type * from "../../src/ts/ts-rs/NumberFormatStyle.d.ts"; export type * from "../../src/ts/ts-rs/Notation.d.ts"; -export type * from "../../src/ts/ts-rs/DatetimeFormatType.d.ts"; +export type * from "../../src/ts/ts-rs/DatetimeFormat.d.ts"; +export type * from "../../src/ts/ts-rs/ConfigDefaultsOptions.d.ts"; +export type * from "../../src/ts/ts-rs/ColumnConfigDefaultsOptions.d.ts"; import type {GetTableOptions} from "../../src/ts/ts-rs/GetTableOptions.d.ts"; import type {PanelOptions} from "../../src/ts/ts-rs/PanelOptions.d.ts"; @@ -116,6 +118,8 @@ import type {RestoreOptions} from "../../src/ts/ts-rs/RestoreOptions.d.ts"; import type {RestoreWorkspaceOptions} from "../../src/ts/ts-rs/RestoreWorkspaceOptions.d.ts"; import type {AddPanelOptions} from "../../src/ts/ts-rs/AddPanelOptions.d.ts"; import type {SaveWorkspaceOptions} from "../../src/ts/ts-rs/SaveWorkspaceOptions.d.ts"; +import type {ConfigDefaultsOptions} from "../../src/ts/ts-rs/ConfigDefaultsOptions.d.ts"; +import type {ColumnConfigDefaultsOptions} from "../../src/ts/ts-rs/ColumnConfigDefaultsOptions.d.ts"; import type {ExportOptions} from "../../src/ts/ts-rs/ExportOptions.d.ts"; import type {GetClientOptions} from "../../src/ts/ts-rs/GetClientOptions.d.ts"; import type {GetViewOptions} from "../../src/ts/ts-rs/GetViewOptions.d.ts"; @@ -135,6 +139,105 @@ pub fn registerPlugin(name: &str) { PLUGIN_REGISTRY.register_plugin(name); } +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Record | null")] + pub type JsConfigDefaults; + + #[wasm_bindgen(typescript_type = "ConfigDefaultsOptions")] + pub type JsConfigDefaultsOptions; + + #[wasm_bindgen(typescript_type = "ColumnConfigDefaultsOptions")] + pub type JsColumnConfigDefaultsOptions; + + #[wasm_bindgen( + typescript_type = "Array<{ plugin?: string; defaults: Record }>" + )] + pub type JsPluginConfigDefaultsSlots; + + #[wasm_bindgen( + typescript_type = "Array<{ plugin?: string; type?: ColumnType; defaults: Record }>" + )] + pub type JsColumnConfigDefaultsSlots; +} + +fn defaults_options( + options: Option>, +) -> ApiResult { + match options { + None => Ok(T::default()), + Some(options) => Ok(options.into().into_serde_ext()?), + } +} + +/// Merge `defaults` into the `plugin_config` defaults every +/// `` on the page applies, where a `null` value removes a +/// default and a `null` `defaults` clears the slot. +/// +/// # Arguments +/// +/// - `defaults` - Values keyed like `plugin_config`, or `null`. +/// - `options.plugin` - Limits the defaults to the named plugin. +/// +/// # JavaScript Examples +/// +/// ```javascript +/// customElements.get("perspective-viewer").setPluginConfigDefaults({group_pad: 10}); +/// ``` +#[wasm_bindgen] +#[allow(non_snake_case)] +pub fn setPluginConfigDefaults( + defaults: JsConfigDefaults, + options: Option, +) -> ApiResult<()> { + let config::ConfigDefaultsOptions { plugin } = defaults_options(options)?; + renderer::set_plugin_config_defaults(&defaults, plugin.as_deref()) +} + +/// Merge `defaults` into the `columns_config` defaults every +/// `` on the page applies, where a `null` value removes a +/// default and a `null` `defaults` clears the slot. +/// +/// # Arguments +/// +/// - `defaults` - Values keyed like one `columns_config` entry, or `null`. +/// - `options.plugin` - Limits the defaults to the named plugin. +/// - `options.type` - Limits the defaults to columns of this view type. +/// +/// # JavaScript Examples +/// +/// ```javascript +/// customElements +/// .get("perspective-viewer") +/// .setColumnConfigDefaults({date_format: {timeZone: "UTC"}}, {type: "datetime"}); +/// ``` +#[wasm_bindgen] +#[allow(non_snake_case)] +pub fn setColumnConfigDefaults( + defaults: JsConfigDefaults, + options: Option, +) -> ApiResult<()> { + let config::ColumnConfigDefaultsOptions { plugin, ty } = defaults_options(options)?; + renderer::set_column_config_defaults(&defaults, plugin.as_deref(), ty.as_deref()) +} + +/// The `plugin_config` defaults set by [`setPluginConfigDefaults`], one entry +/// per slot. +#[wasm_bindgen] +#[allow(non_snake_case)] +pub fn getPluginConfigDefaults() -> ApiResult { + Ok(renderer::get_plugin_config_defaults()?.unchecked_into()) +} + +/// The `columns_config` defaults set by [`setColumnConfigDefaults`], one entry +/// per slot. +#[wasm_bindgen] +#[allow(non_snake_case)] +pub fn getColumnConfigDefaults() -> ApiResult { + Ok(renderer::get_column_config_defaults()?.unchecked_into()) +} + /// Register this crate's Custom Elements in the browser's current session. /// /// This must occur before calling any public API methods on these Custom diff --git a/rust/perspective-viewer/src/rust/queries/plugin_column_styles.rs b/rust/perspective-viewer/src/rust/queries/plugin_column_styles.rs index d9955e3831..c7b6ea0c4f 100644 --- a/rust/perspective-viewer/src/rust/queries/plugin_column_styles.rs +++ b/rust/perspective-viewer/src/rust/queries/plugin_column_styles.rs @@ -10,14 +10,12 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -use itertools::Itertools; use perspective_client::config::ViewConfig; -use perspective_js::utils::{ApiResult, JsValueSerdeExt}; +use perspective_js::utils::ApiResult; -use super::column_locator::is_pivot_only_column; use crate::config::ColumnConfigSchema; -use crate::renderer::{ColumnRole, Renderer}; -use crate::session::SessionMetadata; +use crate::renderer::Renderer; +use crate::session::Session; /// Queries the active plugin for its plugin-scoped [`ColumnConfigSchema`] as /// it applies to `current_value`. @@ -26,15 +24,7 @@ pub fn get_plugin_config_schema( view_config: &ViewConfig, current_value: Option<&serde_json::Map>, ) -> ApiResult { - let plugin = renderer.ensure_plugin_selected()?; - let view_config_js = - wasm_bindgen::JsValue::from_serde_ext(view_config).unwrap_or(wasm_bindgen::JsValue::NULL); - let current_js = wasm_bindgen::JsValue::from_serde_ext(¤t_value) - .unwrap_or(wasm_bindgen::JsValue::NULL); - let raw = plugin._plugin_config_schema(&view_config_js, ¤t_js)?; - serde_wasm_bindgen::from_value::(raw) - .map(|schema| schema.canonicalize()) - .map_err(|e| e.into()) + renderer.query_plugin_config_schema(view_config, current_value) } /// Queries the active plugin for the per-column [`ColumnConfigSchema`]. @@ -44,62 +34,15 @@ pub fn get_plugin_config_schema( pub fn get_column_config_schema( renderer: &Renderer, view_config: &ViewConfig, - metadata: &SessionMetadata, + session: &Session, column_name: &str, current_value: Option<&serde_json::Map>, - abs_max: Option, ) -> ApiResult { - let plugin = renderer.ensure_plugin_selected()?; - let plugin_config = renderer.metadata(); - let names = &plugin_config.config_column_names; - let group = view_config - .columns - .iter() - .find_position(|maybe_s| maybe_s.as_deref() == Some(column_name)) - .and_then(|(idx, _)| names.get(idx)) - .map(|s| s.as_str()); - - let role = if !is_pivot_only_column(column_name, view_config) { - ColumnRole::Column - } else if view_config.split_by.iter().any(|x| x == column_name) { - ColumnRole::SplitBy - } else { - ColumnRole::GroupBy - }; - - let view_type = match role { - ColumnRole::Column => metadata.get_column_view_type(column_name), - _ => metadata - .get_column_table_type(column_name) - .or_else(|| metadata.get_column_view_type(column_name)), - }; - - let Some(view_type) = view_type else { - return Ok(ColumnConfigSchema { fields: vec![] }); - }; - - // Route through serde_json so maps serialize as plain JS objects - // rather than `Map` instances — plugin code accesses these with - // property syntax, not `.get()`. - let current_js = wasm_bindgen::JsValue::from_serde_ext(¤t_value) - .unwrap_or(wasm_bindgen::JsValue::NULL); - let view_config_js = - wasm_bindgen::JsValue::from_serde_ext(view_config).unwrap_or(wasm_bindgen::JsValue::NULL); - - let plugin_config_js = wasm_bindgen::JsValue::from_serde_ext(&renderer.get_plugin_config()) - .unwrap_or(wasm_bindgen::JsValue::NULL); - - let raw = plugin._column_config_schema( - &view_type.to_string(), - group, + renderer.query_column_config_schema_with( + view_config, + session, column_name, - ¤t_js, - &view_config_js, - &plugin_config_js, - role.as_str(), - )?; - - serde_wasm_bindgen::from_value::(raw) - .map(|schema| schema.canonicalize().resolve_stat_defaults(abs_max)) - .map_err(|e| e.into()) + current_value, + &renderer.get_plugin_config(), + ) } diff --git a/rust/perspective-viewer/src/rust/renderer.rs b/rust/perspective-viewer/src/rust/renderer.rs index 4fb5d5c51f..7ad1c5905a 100644 --- a/rust/perspective-viewer/src/rust/renderer.rs +++ b/rust/perspective-viewer/src/rust/renderer.rs @@ -18,6 +18,7 @@ //! references throughout the application. mod activate; +mod config_defaults; mod dispatch; pub mod limits; mod plugin_config; @@ -40,9 +41,10 @@ use yew::html::ImplicitClone; use yew::prelude::*; use self::activate::*; +pub use self::config_defaults::*; pub use self::limits::RenderLimits; pub use self::plugin_config::{ - ColumnConfigMap, ColumnRole, PluginScopedConfig, PreparedRenderer, ValidatedColumnsConfig, + ColumnConfigMap, PluginScopedConfig, PreparedRenderer, ValidatedColumnsConfig, ValidatedPluginConfig, }; use self::plugin_store::*; @@ -130,6 +132,10 @@ pub struct RendererData { /// edits only. pub columns_config_changed: PubSub, + /// Fires after the page-wide config defaults changed and this panel's + /// plugin received them. + pub config_defaults_changed: PubSub<()>, + /// `true` while the active plugin's "rendering N of M" warning is /// dismissable. pub render_warning: Cell, @@ -272,6 +278,7 @@ impl Renderer { column_style_changed: Default::default(), plugin_config_changed: Default::default(), columns_config_changed: Default::default(), + config_defaults_changed: Default::default(), render_warning: Cell::new(true), presize_pending: Cell::new(0), presized_box: Cell::new(None), @@ -688,8 +695,7 @@ impl Renderer { /// and draw it belongs to. `None` ensures *some* plugin is selected (the /// registry default) without changing an existing selection. Returns /// whether the active plugin changed (`None`'s default-selection case - /// reports `false`, preserving first-selection semantics for the - /// swap-restore pass in the draw tasks). + /// reports `false`). pub fn commit_plugin(&self, idx: Option) -> ApiResult { let idx = match idx { Some(idx) => idx, @@ -815,7 +821,7 @@ impl Renderer { } } -fn make_short_name(name: &str) -> String { +pub(crate) fn make_short_name(name: &str) -> String { name.to_lowercase() .chars() .filter(|x| x.is_alphabetic()) @@ -850,6 +856,7 @@ impl Renderer { is_chart, plugin_config, columns_config, + config_defaults: CONFIG_DEFAULTS.with(ConfigDefaults::slots), render_error: self.failure(), } } else { @@ -861,6 +868,7 @@ impl Renderer { is_chart: false, plugin_config: PtrEqRc::default(), columns_config: PtrEqRc::default(), + config_defaults: CONFIG_DEFAULTS.with(ConfigDefaults::slots), render_error: self.failure(), } } diff --git a/rust/perspective-viewer/src/rust/renderer/config_defaults.rs b/rust/perspective-viewer/src/rust/renderer/config_defaults.rs new file mode 100644 index 0000000000..7c318b35dc --- /dev/null +++ b/rust/perspective-viewer/src/rust/renderer/config_defaults.rs @@ -0,0 +1,357 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; + +use perspective_client::proto::ColumnType; +use perspective_js::utils::{ApiResult, JsValueSerdeExt}; +use serde_json::{Map, Value, json}; +use wasm_bindgen::JsValue; + +use super::make_short_name; +use super::registry::*; +use crate::config::{CustomNumberFormatConfig, DatetimeFormat}; +use crate::utils::{PtrEqRc, PubSub}; + +type ConfigMap = Map; + +thread_local! { + /// The page-wide `plugin_config` and `columns_config` default overrides. + pub static CONFIG_DEFAULTS: ConfigDefaults = ConfigDefaults::default(); +} + +/// The page-wide default overrides, held as one immutable value that each +/// change replaces. +#[derive(Default)] +pub struct ConfigDefaults { + slots: RefCell>, + reported: RefCell>, + pub changed: PubSub>, +} + +impl ConfigDefaults { + /// The current value, pointer-equal until the next [`Self::swap`]. + pub fn slots(&self) -> PtrEqRc { + self.slots.borrow().clone() + } + + /// Install `next` as the current value and announce it. + pub fn swap(&self, next: ConfigDefaultsSlots) { + let next = PtrEqRc::new(next); + *self.slots.borrow_mut() = next.clone(); + self.reported.borrow_mut().clear(); + self.changed.emit(next); + } + + /// Log `message` as an error once per installed value. + pub fn report(&self, message: String) { + if self.reported.borrow_mut().insert(message.clone()) { + tracing::error!("{message}"); + } + } +} + +/// Every default override, as one value identified by pointer and never +/// compared by content. +#[derive(Clone, Debug, Default)] +pub struct ConfigDefaultsSlots { + plugin: HashMap, ConfigMap>, + column: HashMap<(Option, Option), ConfigMap>, +} + +impl ConfigDefaultsSlots { + /// This value with `patch` merged into the plugin slot, or `None` when that + /// changes nothing. + pub fn with_plugin(&self, plugin: Option, patch: Option) -> Option { + let slot = merge_slot(self.plugin.get(&plugin), patch)?; + let mut next = self.clone(); + match slot { + Some(map) => next.plugin.insert(plugin, map), + None => next.plugin.remove(&plugin), + }; + + Some(next) + } + + /// This value with `patch` merged into the column slot, or `None` when that + /// changes nothing. + pub fn with_column( + &self, + plugin: Option, + ty: Option, + patch: Option, + ) -> Option { + let key = (plugin, ty); + let slot = merge_slot(self.column.get(&key), patch)?; + let mut next = self.clone(); + match slot { + Some(map) => next.column.insert(key, map), + None => next.column.remove(&key), + }; + + Some(next) + } + + /// The plugin slots that apply to `plugin`, lowest precedence first. + pub fn plugin_layers(&self, plugin: &str) -> Vec<&ConfigMap> { + [None, Some(plugin.to_owned())] + .iter() + .filter_map(|key| self.plugin.get(key)) + .collect() + } + + /// The column slots that apply to a `ty` column of `plugin`, lowest + /// precedence first. + pub fn column_layers(&self, plugin: &str, ty: ColumnType) -> Vec<&ConfigMap> { + let plugin = Some(plugin.to_owned()); + [ + (None, None), + (None, Some(ty)), + (plugin.clone(), None), + (plugin, Some(ty)), + ] + .iter() + .filter_map(|key| self.column.get(key)) + .collect() + } + + /// Whether any column slot applies to some column of `plugin`. + pub fn has_column_layers(&self, plugin: &str) -> bool { + self.column + .keys() + .any(|(p, _)| p.as_deref().is_none_or(|p| p == plugin)) + } + + /// Whether no slot holds anything. + pub fn is_empty(&self) -> bool { + self.plugin.is_empty() && self.column.is_empty() + } + + /// The plugin slots as JSON, unqualified first. + pub fn snapshot_plugin(&self) -> Value { + let mut slots: Vec<_> = self.plugin.iter().collect(); + slots.sort_by(|a, b| a.0.cmp(b.0)); + Value::Array( + slots + .into_iter() + .map(|(plugin, defaults)| match plugin { + Some(plugin) => json!({ "plugin": plugin, "defaults": defaults }), + None => json!({ "defaults": defaults }), + }) + .collect(), + ) + } + + /// The column slots as JSON, unqualified first. + pub fn snapshot_column(&self) -> Value { + let mut slots: Vec<_> = self + .column + .iter() + .map(|((plugin, ty), defaults)| ((plugin, ty.map(|t| t.to_string())), defaults)) + .collect(); + + slots.sort_by(|a, b| a.0.cmp(&b.0)); + Value::Array( + slots + .into_iter() + .map(|((plugin, ty), defaults)| { + let mut entry = Map::new(); + if let Some(plugin) = plugin { + entry.insert("plugin".to_owned(), Value::from(plugin.clone())); + } + + if let Some(ty) = ty { + entry.insert("type".to_owned(), Value::from(ty)); + } + + entry.insert("defaults".to_owned(), Value::Object(defaults.clone())); + Value::Object(entry) + }) + .collect(), + ) + } +} + +impl ConfigDefaultsSlots { + /// `Err` naming the first slot whose `date_format` or `number_format` is + /// invalid. + fn check_formats(&self) -> Result<(), String> { + for slot in self.plugin.values().chain(self.column.values()) { + if let Some(format) = slot.get("date_format") { + serde_json::from_value::(format.clone()) + .map_err(|e| e.to_string()) + .and_then(|format| format.validate()) + .map_err(|e| format!("Invalid `date_format` default: {e}"))?; + } + + if let Some(format) = slot.get("number_format") { + serde_json::from_value::(format.clone()) + .map_err(|e| format!("Invalid `number_format` default: {e}"))?; + } + } + + Ok(()) + } +} + +/// Merge `defaults` into the `plugin_config` defaults slot `plugin` names, as +/// `setPluginConfigDefaults()` does. +pub fn set_plugin_config_defaults(defaults: &JsValue, plugin: Option<&str>) -> ApiResult<()> { + let patch = parse_patch(defaults)?; + let plugin = plugin.map(registered_plugin).transpose()?; + CONFIG_DEFAULTS + .with(|registry| swap_checked(registry, registry.slots().with_plugin(plugin, patch))) +} + +/// Merge `defaults` into the `columns_config` defaults slot `plugin` and `ty` +/// name, as `setColumnConfigDefaults()` does. +pub fn set_column_config_defaults( + defaults: &JsValue, + plugin: Option<&str>, + ty: Option<&str>, +) -> ApiResult<()> { + let patch = parse_patch(defaults)?; + let plugin = plugin.map(registered_plugin).transpose()?; + let ty = ty + .map(|ty| { + ColumnType::try_from(ty).map_err(|_| { + format!( + "`type` must be one of `integer`, `float`, `string`, `boolean`, `date` or \ + `datetime`, not `{ty}`" + ) + }) + }) + .transpose()?; + + CONFIG_DEFAULTS + .with(|registry| swap_checked(registry, registry.slots().with_column(plugin, ty, patch))) +} + +/// The `plugin_config` defaults slots, as `getPluginConfigDefaults()` returns +/// them. +pub fn get_plugin_config_defaults() -> ApiResult { + let snapshot = CONFIG_DEFAULTS.with(|registry| registry.slots().snapshot_plugin()); + Ok(JsValue::from_serde_ext(&snapshot)?) +} + +/// The `columns_config` defaults slots, as `getColumnConfigDefaults()` returns +/// them. +pub fn get_column_config_defaults() -> ApiResult { + let snapshot = CONFIG_DEFAULTS.with(|registry| registry.slots().snapshot_column()); + Ok(JsValue::from_serde_ext(&snapshot)?) +} + +fn swap_checked(registry: &ConfigDefaults, next: Option) -> ApiResult<()> { + if let Some(next) = next { + next.check_formats()?; + registry.swap(next); + } + + Ok(()) +} + +fn parse_patch(defaults: &JsValue) -> ApiResult> { + if defaults.is_null() || defaults.is_undefined() { + return Ok(None); + } + + match defaults.into_serde_ext::()? { + Value::Object(patch) => { + check_patch(&patch)?; + Ok(Some(patch)) + }, + value => Err(format!("`defaults` must be an object or `null`, not `{value}`").into()), + } +} + +fn check_patch(patch: &ConfigMap) -> Result<(), String> { + for (key, value) in patch { + match value { + Value::Array(_) => { + return Err(format!("`{key}` must be a scalar, an object or `null`")); + }, + Value::Object(sub) => check_patch(sub)?, + _ if (key == "date_format" || key == "number_format") && !value.is_null() => { + return Err(format!("`{key}` must be an object or `null`")); + }, + _ => {}, + } + } + + Ok(()) +} + +fn registered_plugin(name: &str) -> Result { + let configs = PLUGIN_REGISTRY.plugin_configs(); + let short_name = make_short_name(name); + configs + .iter() + .find(|config| make_short_name(&config.name) == short_name) + .map(|config| config.name.clone()) + .ok_or_else(|| { + let names = configs + .iter() + .map(|config| format!("`{}`", config.name)) + .collect::>() + .join(", "); + + format!("No plugin named `{name}`; registered plugins are {names}") + }) +} + +fn merge_slot(current: Option<&ConfigMap>, patch: Option) -> Option> { + let mut next = current.cloned().unwrap_or_default(); + let changed = match patch { + None => { + let changed = !next.is_empty(); + next.clear(); + changed + }, + Some(patch) => merge_object(&mut next, patch), + }; + + changed.then(|| (!next.is_empty()).then_some(next)) +} + +fn merge_object(target: &mut ConfigMap, patch: ConfigMap) -> bool { + let mut changed = false; + for (key, value) in patch { + match value { + Value::Null => changed |= target.remove(&key).is_some(), + Value::Object(sub) => { + let mut next = match target.get(&key) { + Some(Value::Object(current)) => current.clone(), + _ => ConfigMap::new(), + }; + + merge_object(&mut next, sub); + let next = (!next.is_empty()).then_some(Value::Object(next)); + if target.get(&key) != next.as_ref() { + changed = true; + match next { + Some(next) => target.insert(key, next), + None => target.remove(&key), + }; + } + }, + value => { + if target.get(&key) != Some(&value) { + target.insert(key, value); + changed = true; + } + }, + } + } + + changed +} diff --git a/rust/perspective-viewer/src/rust/renderer/plugin_config.rs b/rust/perspective-viewer/src/rust/renderer/plugin_config.rs index ad564f676b..5fe413d4e7 100644 --- a/rust/perspective-viewer/src/rust/renderer/plugin_config.rs +++ b/rust/perspective-viewer/src/rust/renderer/plugin_config.rs @@ -25,6 +25,7 @@ use serde_json::Value; use wasm_bindgen::prelude::*; use super::Renderer; +use super::config_defaults::{CONFIG_DEFAULTS, ConfigDefaults}; use super::state::PluginRef; use crate::config::*; use crate::js::plugin::JsPerspectiveViewerPlugin; @@ -237,7 +238,7 @@ pub fn validate_columns_config( } resolve_css(&col, &mut cfg); - strip_default_values(&schema, &mut cfg); + strip_default_values(&schema, &mut cfg, view_type_of(&col)); } stripped.push((col, cfg)); @@ -568,18 +569,22 @@ impl Renderer { out } - /// Restore-prep snapshot: like [`Self::all_columns_configs`], but - /// for each column also materializes any `ControlSpec::Number` - /// fields the schema declares with `include: true` that aren't - /// already in the bucket entry. The materialized value is the - /// schema's `default`, which the schema computes from cached - /// column stats (via [`Self::query_column_config_schema`]). + /// The active plugin's committed per-column configs as delivered to it: + /// stored values plus default overrides and `include: true` numbers it does + /// not store. pub async fn all_columns_configs_materialized( &self, view_config: &ViewConfig, session: &Session, ) -> ColumnConfigMap { let mut configs = self.committed_columns_configs(); + let plugin = self.metadata().name.clone(); + if CONFIG_DEFAULTS.with(|d| d.slots().has_column_layers(&plugin)) { + for column in configurable_columns(view_config) { + configs.entry(column).or_default(); + } + } + let mut to_warm: Vec = vec![]; for (col, entry) in &configs { if session @@ -589,11 +594,13 @@ impl Renderer { { continue; } - let Ok(schema) = - self.query_column_config_schema(view_config, session, col, Some(entry)) + + let current = Some(entry).filter(|e| !e.is_empty()); + let Ok(schema) = self.query_column_config_schema(view_config, session, col, current) else { continue; }; + let needs_warm = schema.leaf_fields().into_iter().any(|f| { matches!( f, @@ -605,6 +612,7 @@ impl Renderer { } if !entry.contains_key(key) ) }); + if needs_warm { to_warm.push(col.clone()); } @@ -619,14 +627,28 @@ impl Renderer { join_all(futs).await; } + let Ok(target) = self.active_target() else { + return configs; + }; + + let plugin_config = self.committed_plugin_config(); + let env = ColumnSchemaEnv { + view_config, + session, + view_schema: None, + column_stats: true, + plugin_config: &plugin_config, + }; + for (col, entry) in &mut configs { - let Ok(schema) = - self.query_column_config_schema(view_config, session, col, Some(entry)) + let current = Some(&*entry).filter(|e| !e.is_empty()).cloned(); + let Ok(resolved) = + self.query_column_config_schema_resolved(&target, &env, col, current.as_ref()) else { continue; }; - for field in schema.leaf_fields() { + for field in resolved.schema.leaf_fields() { let ControlSpec::Number { key, default, @@ -647,11 +669,32 @@ impl Renderer { entry.insert(key.clone(), serde_json::Value::Number(num)); } + + materialize_overrides(entry, &resolved); } + configs.retain(|_, entry| !entry.is_empty()); configs } + /// The active plugin's committed `plugin_config` as delivered to it: stored + /// values plus the default overrides it does not store. + pub fn plugin_config_materialized(&self, view_config: &ViewConfig) -> ConfigMap { + let mut config = self.committed_plugin_config(); + let plugin = self.metadata().name.clone(); + let has_layers = CONFIG_DEFAULTS.with(|d| !d.slots().plugin_layers(&plugin).is_empty()); + + if has_layers + && let Ok(target) = self.active_target() + && let Ok(resolved) = + self.query_plugin_config_schema_resolved(&target, view_config, Some(&config)) + { + materialize_overrides(&mut config, &resolved); + } + + config + } + /// Clear the active plugin's per-column config map. pub fn reset_columns_configs(&self) { if let Some(n) = self.active_plugin_name() { @@ -733,7 +776,8 @@ impl Renderer { } self.resolve_css_refs_in(&column_name, &mut update.value); - strip_default_values(&schema, &mut update.value); + let view_type = session.metadata().get_column_view_type(&column_name); + strip_default_values(&schema, &mut update.value, view_type); } let next = self.edit_bucket(&n, |bucket| { @@ -802,10 +846,10 @@ impl Renderer { } } - /// Synchronously query the active plugin's [`ColumnConfigSchema`] as it - /// applies to `current_value`, the plugin-config state the schema should - /// describe. - fn query_plugin_config_schema( + /// The active plugin's plugin-level [`ColumnConfigSchema`] for + /// `current_value`, the plugin-config state it should describe, with + /// default overrides applied. + pub(crate) fn query_plugin_config_schema( &self, view_config: &ViewConfig, current_value: Option<&ConfigMap>, @@ -814,27 +858,63 @@ impl Renderer { self.query_plugin_config_schema_for(&target, view_config, current_value) } - /// [`Self::query_plugin_config_schema`] against an explicit target. fn query_plugin_config_schema_for( &self, target: &PluginTarget, view_config: &ViewConfig, current_value: Option<&ConfigMap>, ) -> ApiResult { + self.query_plugin_config_schema_resolved(target, view_config, current_value) + .map(|resolved| resolved.schema) + } + + fn query_plugin_config_schema_resolved( + &self, + target: &PluginTarget, + view_config: &ViewConfig, + current_value: Option<&ConfigMap>, + ) -> ApiResult { + let defaults = CONFIG_DEFAULTS.with(ConfigDefaults::slots); + let plugin = &target.static_config.name; + let layers = defaults.plugin_layers(plugin); + let current = with_plain_overrides(current_value, &layers); let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL); - let current_js = JsValue::from_serde_ext(¤t_value).unwrap_or(JsValue::NULL); + let current_js = JsValue::from_serde_ext(¤t).unwrap_or(JsValue::NULL); let raw = target .element ._plugin_config_schema(&view_config_js, ¤t_js)?; - serde_wasm_bindgen::from_value::(raw) - .map(|schema| schema.canonicalize()) - .map_err(|e| e.into()) + let schema = serde_wasm_bindgen::from_value::(raw)?.canonicalize(); + Ok(ResolvedSchema::apply(schema, &layers, None, plugin, None)) + } + + /// The active plugin's [`ColumnConfigSchema`] for `column_name` under + /// `plugin_config`, with default overrides applied; empty until the view's + /// schema is known. + pub(crate) fn query_column_config_schema_with( + &self, + view_config: &ViewConfig, + session: &Session, + column_name: &str, + current_value: Option<&ConfigMap>, + plugin_config: &ConfigMap, + ) -> ApiResult { + if !session.metadata().has_view_schema() { + return Ok(ColumnConfigSchema::default()); + } + + let target = self.active_target()?; + let env = ColumnSchemaEnv { + view_config, + session, + view_schema: None, + column_stats: true, + plugin_config, + }; + + self.query_column_config_schema_for(&target, &env, column_name, current_value) } - /// Per-column counterpart of [`query_plugin_config_schema`]. Used by - /// the columns-config write paths (strip-on-write) and the - /// restore-prep snapshot (materialize-on-read). fn query_column_config_schema( &self, view_config: &ViewConfig, @@ -854,10 +934,6 @@ impl Renderer { self.query_column_config_schema_for(&target, &env, column_name, current_value) } - /// [`Self::query_column_config_schema`] against an explicit target: the - /// column's group comes from the TARGET's static config, and its view type - /// from `view_schema` when given (a config not yet bound to a `View`), else - /// from the bound `View`'s schema. fn query_column_config_schema_for( &self, target: &PluginTarget, @@ -865,6 +941,17 @@ impl Renderer { column_name: &str, current_value: Option<&ConfigMap>, ) -> ApiResult { + self.query_column_config_schema_resolved(target, env, column_name, current_value) + .map(|resolved| resolved.schema) + } + + fn query_column_config_schema_resolved( + &self, + target: &PluginTarget, + env: &ColumnSchemaEnv<'_>, + column_name: &str, + current_value: Option<&ConfigMap>, + ) -> ApiResult { let ColumnSchemaEnv { view_config, session, @@ -909,10 +996,14 @@ impl Renderer { }; let Some(view_type) = view_type else { - return Ok(ColumnConfigSchema { fields: vec![] }); + return Ok(ResolvedSchema::default()); }; - let current_js = JsValue::from_serde_ext(¤t_value).unwrap_or(JsValue::NULL); + let defaults = CONFIG_DEFAULTS.with(ConfigDefaults::slots); + let plugin = &target.static_config.name; + let layers = defaults.column_layers(plugin, view_type); + let current = with_plain_overrides(current_value, &layers); + let current_js = JsValue::from_serde_ext(¤t).unwrap_or(JsValue::NULL); let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL); let plugin_config_js = JsValue::from_serde_ext(plugin_config).unwrap_or(JsValue::NULL); let raw = target.element._column_config_schema( @@ -933,9 +1024,17 @@ impl Renderer { }) .flatten(); - serde_wasm_bindgen::from_value::(raw) - .map(|schema| schema.canonicalize().resolve_stat_defaults(abs_max)) - .map_err(|e| e.into()) + let schema = serde_wasm_bindgen::from_value::(raw)? + .canonicalize() + .resolve_stat_defaults(abs_max); + + Ok(ResolvedSchema::apply( + schema, + &layers, + Some(view_type), + plugin, + Some(column_name), + )) } /// Merge an update into the active plugin's plugin-level config map: @@ -993,7 +1092,7 @@ impl Renderer { tracing::error!("Dropping `plugin_config`.`{key}`: {error}"); } - strip_default_values(schema, &mut update.value); + strip_default_values(schema, &mut update.value, None); } self.edit_bucket(&n, |bucket| { @@ -1021,14 +1120,177 @@ impl Renderer { } } -/// Canonicalize every CSS-valued key of `map` and check every `Enum`-valued -/// key against its variants, removing and reporting the keys that fail. -/// The schema is the one queried for `map`'s own values, so an `Enum`'s -/// variants are exactly the values the column's type accepts. +/// A schema with default overrides applied, and the override-only value of +/// every key they set. +#[derive(Default)] +struct ResolvedSchema { + schema: ColumnConfigSchema, + overrides: OverrideValues, + view_type: Option, +} + +impl ResolvedSchema { + fn apply( + mut schema: ColumnConfigSchema, + layers: &[&ConfigMap], + view_type: Option, + plugin: &str, + column: Option<&str>, + ) -> Self { + if layers.is_empty() { + return Self { + schema, + overrides: OverrideValues::new(), + view_type, + }; + } + + let (overrides, errors) = + schema.apply_defaults(layers, view_type == Some(ColumnType::Float)); + + let column = column + .map(|column| format!(" column `{column}`")) + .unwrap_or_default(); + + for (key, error) in errors { + CONFIG_DEFAULTS.with(|d| { + d.report(format!( + "Ignoring default override `{key}` for plugin `{plugin}`{column}: {error}" + )) + }); + } + + Self { + schema, + overrides, + view_type, + } + } + + /// The `number_format` default this schema resolves to, overrides included. + fn number_format_default(&self) -> Option { + self.schema + .leaf_fields() + .into_iter() + .find_map(|spec| match spec { + ControlSpec::NumberFormat { default } => Some(default.clone().unwrap_or_default()), + _ => None, + }) + } +} + +/// `current` with every non-object value of `layers` it lacks, the highest +/// layer winning. +fn with_plain_overrides(current: Option<&ConfigMap>, layers: &[&ConfigMap]) -> Option { + if layers.is_empty() { + return current.cloned(); + } + + let mut out = current.cloned().unwrap_or_default(); + for layer in layers.iter().rev() { + for (key, value) in layer.iter() { + if !value.is_object() && !out.contains_key(key) { + out.insert(key.clone(), value.clone()); + } + } + } + + Some(out) +} + +/// Every column a plugin may configure under `view_config`. +fn configurable_columns(view_config: &ViewConfig) -> Vec { + view_config + .columns + .iter() + .flatten() + .chain(view_config.group_by.iter()) + .chain(view_config.split_by.iter()) + .cloned() + .collect() +} + +/// Write the overrides `resolved` applied into `entry`, a stored config being +/// delivered: plain keys it lacks, and each format layered under its stored +/// value. +fn materialize_overrides(entry: &mut ConfigMap, resolved: &ResolvedSchema) { + for (key, value) in &resolved.overrides { + match key.as_str() { + "date_format" => { + let Ok(overrides) = serde_json::from_value::(value.clone()) else { + continue; + }; + + let stored = stored_format::(entry, key); + if let Ok(delivered) = serde_json::to_value(overrides.overlay(&stored)) { + entry.insert(key.clone(), delivered); + } + }, + "number_format" => { + let Ok(overrides) = + serde_json::from_value::(value.clone()) + else { + continue; + }; + + let stored = stored_format::(entry, key); + let mut delivered = overrides.overlay(&stored); + let full = NumberFormatDefaults::resolve( + Some( + &resolved + .number_format_default() + .unwrap_or_default() + .overlay(&stored), + ), + resolved.view_type == Some(ColumnType::Float), + ); + + if delivered.minimum_fraction_digits.is_some() + != delivered.maximum_fraction_digits.is_some() + { + delivered.minimum_fraction_digits = Some(full.fraction.0); + delivered.maximum_fraction_digits = Some(full.fraction.1); + } + + if delivered.minimum_significant_digits.is_some() + != delivered.maximum_significant_digits.is_some() + { + delivered.minimum_significant_digits = Some(full.significant.0); + delivered.maximum_significant_digits = Some(full.significant.1); + } + + if let Ok(delivered) = serde_json::to_value(delivered) { + entry.insert(key.clone(), delivered); + } + }, + _ => { + entry.entry(key.clone()).or_insert_with(|| value.clone()); + }, + } + } +} + +fn stored_format(entry: &ConfigMap, key: &str) -> T { + entry + .get(key) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or_default() +} + +/// Canonicalize every CSS-valued key of `map`, check every `Enum`-valued key +/// against its variants and every format against its type, removing and +/// reporting the keys that fail. fn normalize_values( schema: &ColumnConfigSchema, map: &mut serde_json::Map, ) -> Vec<(String, String)> { + let leaves = schema.leaf_fields(); + let owns = |key: &str, matches: fn(&ControlSpec) -> bool| { + leaves + .iter() + .any(|spec| matches(spec) && spec.serialized_keys().contains(&key)) + }; + let mut errors = vec![]; for (key, value) in map.iter_mut() { if let Some(kind) = schema.css_kind_of(key) { @@ -1052,6 +1314,20 @@ fn normalize_values( errors.push((key.clone(), format!("{value} is not one of {expected}"))); } + } else if owns(key, |spec| { + matches!(spec, ControlSpec::DatetimeFormat { .. }) + }) { + let checked = serde_json::from_value::(value.clone()) + .map_err(|e| e.to_string()) + .and_then(|format| format.validate()); + + if let Err(error) = checked { + errors.push((key.clone(), error)); + } + } else if owns(key, |spec| matches!(spec, ControlSpec::NumberFormat { .. })) + && let Err(error) = serde_json::from_value::(value.clone()) + { + errors.push((key.clone(), error.to_string())); } } @@ -1065,6 +1341,7 @@ fn normalize_values( fn strip_default_values( schema: &ColumnConfigSchema, map: &mut serde_json::Map, + view_type: Option, ) { let leaves = schema.leaf_fields(); map.retain(|key, value| { @@ -1072,6 +1349,50 @@ fn strip_default_values( .iter() .any(|spec| matches_declared_default(spec, key, value)) }); + + for spec in leaves { + match spec { + ControlSpec::DatetimeFormat { default } => { + let Some(Ok(stored)) = map + .get("date_format") + .map(|v| serde_json::from_value::(v.clone())) + else { + continue; + }; + + let inherited = DatetimeFormat::base(view_type == Some(ColumnType::Datetime)) + .overlay(&default.clone().unwrap_or_default()); + + let diff = stored.diff(&inherited); + match serde_json::to_value(&diff) { + Ok(value) if !diff.is_empty() => map.insert("date_format".to_owned(), value), + _ => map.remove("date_format"), + }; + }, + ControlSpec::NumberFormat { default } => { + let Some(Ok(stored)) = map + .get("number_format") + .map(|v| serde_json::from_value::(v.clone())) + else { + continue; + }; + + let defaults = NumberFormatDefaults::resolve( + default.as_ref(), + view_type == Some(ColumnType::Float), + ); + + let filtered = stored.filter_default(&defaults); + match serde_json::to_value(&filtered) { + Ok(value) if filtered != CustomNumberFormatConfig::default() => { + map.insert("number_format".to_owned(), value) + }, + _ => map.remove("number_format"), + }; + }, + _ => {}, + } + } } fn matches_declared_default(spec: &ControlSpec, key: &str, value: &Value) -> bool { diff --git a/rust/perspective-viewer/src/rust/renderer/props.rs b/rust/perspective-viewer/src/rust/renderer/props.rs index 6120fe7a04..bba679ba2d 100644 --- a/rust/perspective-viewer/src/rust/renderer/props.rs +++ b/rust/perspective-viewer/src/rust/renderer/props.rs @@ -13,6 +13,7 @@ use std::rc::Rc; use crate::config::PluginStaticConfig; +use crate::renderer::config_defaults::ConfigDefaultsSlots; use crate::renderer::limits::RenderLimits; use crate::renderer::plugin_config::ColumnConfigMap; use crate::renderer::state::RenderError; @@ -57,6 +58,9 @@ pub struct RendererProps { /// `Renderer::to_props` on `columns_config_changed` and `plugin_changed`. pub columns_config: PtrEqRc, + /// The page-wide config defaults, compared by pointer. + pub config_defaults: PtrEqRc, + /// The failure of the last draw, which stands until the next commit. pub render_error: Option, } diff --git a/rust/perspective-viewer/src/rust/tasks/apply_config_defaults.rs b/rust/perspective-viewer/src/rust/tasks/apply_config_defaults.rs new file mode 100644 index 0000000000..a9bdc3b722 --- /dev/null +++ b/rust/perspective-viewer/src/rust/tasks/apply_config_defaults.rs @@ -0,0 +1,84 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use futures::FutureExt; +use perspective_client::clone; +use perspective_js::utils::*; +use wasm_bindgen::JsValue; + +use crate::renderer::{CONFIG_DEFAULTS, Renderer}; +use crate::session::{EditDelta, OpKind, Session}; +use crate::utils::{AddListener, Subscription}; + +/// Re-deliver this panel's plugin config whenever the page-wide config defaults +/// change. +pub(crate) fn wire_config_defaults_sub(session: &Session, renderer: &Renderer) -> Subscription { + CONFIG_DEFAULTS.with(|defaults| { + defaults.changed.add_listener({ + clone!(session, renderer); + move |_| apply_config_defaults(&session, &renderer) + }) + }) +} + +fn apply_config_defaults(session: &Session, renderer: &Renderer) { + let kind = OpKind::Edit { + delta: EditDelta::Renderer, + swaps_plugin: false, + }; + + let ticket = session.submit(kind, { + clone!(session, renderer); + move |_ctx| { + Box::pin(async move { + Ok(Some( + async move { + if renderer.active_plugin().is_some() && session.get_view().is_some() { + deliver_config_defaults(&session, &renderer).await?; + } + + renderer.config_defaults_changed.emit(()); + Ok(()) + } + .boxed_local(), + )) + }) + } + }); + + ApiFuture::spawn(ticket.settle()); +} + +async fn deliver_config_defaults(session: &Session, renderer: &Renderer) -> ApiResult<()> { + let view_config = session.committed_view_config().clone(); + let plugin_token = JsValue::from_serde_ext(&renderer.plugin_config_materialized(&view_config))?; + + let columns_configs = renderer + .all_columns_configs_materialized(&view_config, session) + .await; + + renderer + .ensure_plugin_selected()? + .restore(&plugin_token, Some(&columns_configs))?; + + if renderer.is_plugin_hidden() { + renderer.set_data_stale(); + return Ok(()); + } + + clone!(session); + renderer + .update_lazy(async move { Ok(session.get_view_with_dimensions()) }) + .await?; + + Ok(()) +} diff --git a/rust/perspective-viewer/src/rust/tasks/create_panel.rs b/rust/perspective-viewer/src/rust/tasks/create_panel.rs index 603ef49537..c2b4d6348e 100644 --- a/rust/perspective-viewer/src/rust/tasks/create_panel.rs +++ b/rust/perspective-viewer/src/rust/tasks/create_panel.rs @@ -35,7 +35,10 @@ pub(crate) fn wire_panel_subs( session: &Session, renderer: &Renderer, ) -> Vec { - let mut subs = vec![wire_panel_render_sub(session, renderer)]; + let mut subs = vec![ + wire_panel_render_sub(session, renderer), + super::wire_config_defaults_sub(session, renderer), + ]; subs.extend(wire_panel_events( elem, session, diff --git a/rust/perspective-viewer/src/rust/tasks/mod.rs b/rust/perspective-viewer/src/rust/tasks/mod.rs index 4e260dcff2..760b97e7be 100644 --- a/rust/perspective-viewer/src/rust/tasks/mod.rs +++ b/rust/perspective-viewer/src/rust/tasks/mod.rs @@ -21,6 +21,7 @@ //! [`Renderer`]: crate::renderer::Renderer //! [`Presentation`]: crate::presentation::Presentation +mod apply_config_defaults; mod apply_global_filters; mod auto_pause; mod copy_export; @@ -55,6 +56,7 @@ pub(crate) const STAGING_DEADLINE_MS: i32 = 500; /// [`presize_panels::plugin_chrome`]). pub(crate) const CHROME_FALLBACK: (f64, f64) = (8.0, 33.0); +pub(crate) use self::apply_config_defaults::*; pub use self::apply_global_filters::*; pub use self::auto_pause::*; pub use self::copy_export::*; diff --git a/rust/perspective-viewer/src/rust/tasks/pipeline.rs b/rust/perspective-viewer/src/rust/tasks/pipeline.rs index 9eb98011d3..bb467dcf10 100644 --- a/rust/perspective-viewer/src/rust/tasks/pipeline.rs +++ b/rust/perspective-viewer/src/rust/tasks/pipeline.rs @@ -167,10 +167,6 @@ pub fn just_render(session: &Session, renderer: &Renderer) -> ApiResult { - if committed.activate { - renderer.activate_committed_plugin()?; - } - - committed.plugin_swapped - }, + match &committed { + Some(committed) if committed.activate => renderer.activate_committed_plugin()?, + Some(_) => {}, None => { renderer.ensure_plugin_selected()?; - false }, }; let plugin = renderer.active_plugin().ok_or("No Plugin")?; @@ -315,34 +305,34 @@ pub(crate) async fn run_locked( }; let view_config_snapshot = session.committed_view_config().clone(); - let changed = match committed { - Some(committed) => { - let changed = - committed.plugin_config_changed || committed.columns_config_changed; - - if changed || plugin_swapped { - let plugin_config = renderer.committed_plugin_config(); - let plugin_update = - JsValue::from_serde_ext(&plugin_config).unwrap_or(JsValue::NULL); - - let columns_config = renderer - .all_columns_configs_materialized(&view_config_snapshot, &session) - .await; - - plugin.restore(&plugin_update, Some(&columns_config))?; - if committed.columns_config_changed { - renderer.columns_config_changed.emit(columns_config); - } - - if committed.plugin_config_changed { - renderer.plugin_config_changed.emit(plugin_config); - } + let changed = committed + .as_ref() + .is_some_and(|c| c.plugin_config_changed || c.columns_config_changed); + + if changed || !renderer.is_plugin_activated()? { + let plugin_config = renderer.committed_plugin_config(); + let plugin_update = JsValue::from_serde_ext( + &renderer.plugin_config_materialized(&view_config_snapshot), + ) + .unwrap_or(JsValue::NULL); + + let columns_config = renderer + .all_columns_configs_materialized(&view_config_snapshot, &session) + .await; + + plugin.restore(&plugin_update, Some(&columns_config))?; + if let Some(committed) = &committed { + if committed.columns_config_changed { + renderer + .columns_config_changed + .emit(renderer.committed_columns_configs()); } - changed - }, - None => false, - }; + if committed.plugin_config_changed { + renderer.plugin_config_changed.emit(plugin_config); + } + } + } if spec .presentation diff --git a/rust/perspective-viewer/src/rust/tasks/send_column_config.rs b/rust/perspective-viewer/src/rust/tasks/send_column_config.rs index ed71295b59..1b3e8e9d7d 100644 --- a/rust/perspective-viewer/src/rust/tasks/send_column_config.rs +++ b/rust/perspective-viewer/src/rust/tasks/send_column_config.rs @@ -57,7 +57,7 @@ pub fn send_column_config( .await; let plugin_token = wasm_bindgen::JsValue::from_serde_ext( - &renderer.committed_plugin_config(), + &renderer.plugin_config_materialized(&view_config_snapshot), ) .unwrap(); @@ -70,11 +70,9 @@ pub fn send_column_config( .update_lazy(async move { Ok(session.get_view_with_dimensions()) }) .await?; - renderer - .columns_config_changed - .emit(columns_configs.clone()); - - renderer.column_style_changed.emit(columns_configs); + let stored = renderer.committed_columns_configs(); + renderer.columns_config_changed.emit(stored.clone()); + renderer.column_style_changed.emit(stored); Ok(()) } .boxed_local(), diff --git a/rust/perspective-viewer/src/rust/tasks/send_plugin_config.rs b/rust/perspective-viewer/src/rust/tasks/send_plugin_config.rs index b6c5fdae18..da81be5fc1 100644 --- a/rust/perspective-viewer/src/rust/tasks/send_plugin_config.rs +++ b/rust/perspective-viewer/src/rust/tasks/send_plugin_config.rs @@ -63,8 +63,11 @@ pub fn send_plugin_config(session: &Session, renderer: &Renderer, update: Column /// announce the plugin-level change. pub(super) async fn deliver_plugin_config(session: &Session, renderer: &Renderer) -> ApiResult<()> { let plugin_config = renderer.committed_plugin_config(); - let plugin_token = wasm_bindgen::JsValue::from_serde_ext(&plugin_config).unwrap(); let view_config_snapshot = session.committed_view_config().clone(); + let plugin_token = wasm_bindgen::JsValue::from_serde_ext( + &renderer.plugin_config_materialized(&view_config_snapshot), + ) + .unwrap(); let columns_configs = renderer .all_columns_configs_materialized(&view_config_snapshot, session) .await; diff --git a/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs b/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs index 5bbf91c08f..3509fc68a9 100644 --- a/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs +++ b/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs @@ -47,13 +47,17 @@ pub fn sync_update_panels( let WorkspaceConfigUpdate { active, layout, - panels, + mut panels, derived_tables, global_filters, masters, palette, } = update.into_serde_ext()?; + for panel in panels.iter_mut().flat_map(|panels| panels.values_mut()) { + panel.migrate(); + } + let palette = match palette { OptionalUpdate::Missing => None, OptionalUpdate::SetDefault => Some(Default::default()), diff --git a/rust/perspective-viewer/src/rust/tasks/transactional_restore.rs b/rust/perspective-viewer/src/rust/tasks/transactional_restore.rs index 377bf69b72..434869b528 100644 --- a/rust/perspective-viewer/src/rust/tasks/transactional_restore.rs +++ b/rust/perspective-viewer/src/rust/tasks/transactional_restore.rs @@ -252,7 +252,6 @@ fn commit( title, } = prepared; - let had_plugin = renderer.active_plugin().is_some(); let activate = plugin.is_some(); let binding = session.commit_view(view, title); let changed = renderer.commit(PreparedRenderer { @@ -265,7 +264,6 @@ fn commit( let committed = Committed { activate, - plugin_swapped: activate && had_plugin, plugin_config_changed: changed.plugin_config, columns_config_changed: changed.columns_config, }; diff --git a/rust/perspective-viewer/src/ts/column-format.ts b/rust/perspective-viewer/src/ts/column-format.ts index b1eeeb5932..dfc509b811 100644 --- a/rust/perspective-viewer/src/ts/column-format.ts +++ b/rust/perspective-viewer/src/ts/column-format.ts @@ -18,17 +18,13 @@ * * The format configs mirror the `Intl.NumberFormatOptions` / * `Intl.DateTimeFormatOptions` shapes one-for-one — they're written - * straight into the respective constructors. The `date_format.format` - * discriminator ("simple" | "custom") selects between two derivation - * paths: simple uses `dateStyle` / `timeStyle`, custom uses the - * per-field overrides (year / month / day / ...). + * straight into the respective constructors. */ import type { CustomNumberFormatConfig } from "./ts-rs/CustomNumberFormatConfig.d.ts"; import type { NumberFormatStyle } from "./ts-rs/NumberFormatStyle.d.ts"; import type { Notation } from "./ts-rs/Notation.d.ts"; -import type { DatetimeFormatType } from "./ts-rs/DatetimeFormatType.d.ts"; -import type { SimpleDatetimeStyleConfig } from "./ts-rs/SimpleDatetimeStyleConfig.d.ts"; +import type { DatetimeFormat } from "./ts-rs/DatetimeFormat.d.ts"; /** * A numeric column's `number_format` (`columns_config` value), exactly as @@ -42,18 +38,58 @@ export type NumberFormatConfig = CustomNumberFormatConfig & Partial; /** - * A datetime column's `date_format` (`columns_config` value) — the Rust - * `DatetimeFormatType` union: a `Simple` preset (`dateStyle` / - * `timeStyle`), or per-part custom fields discriminated by - * `format: "custom"`. + * A datetime column's `date_format` (`columns_config` value), a subset of + * `Intl.DateTimeFormatOptions`. */ -export type DateFormatConfig = DatetimeFormatType; +export type DateFormatConfig = DatetimeFormat; + +/** The `date_format` keys that apply as one group. */ +export const DATE_FORMAT_SHAPE_KEYS = [ + "dateStyle", + "timeStyle", + "weekday", + "year", + "month", + "day", + "hour", + "minute", + "second", + "fractionalSecondDigits", +] as const satisfies readonly (keyof DateFormatConfig)[]; + +/** The `date_format` keys that apply individually. */ +export const DATE_FORMAT_MODIFIER_KEYS = [ + "timeZone", + "hour12", +] as const satisfies readonly (keyof DateFormatConfig)[]; + +/** Whether `cfg` sets any of the `date_format` keys that apply as one group. */ +export function hasDateFormatShape( + cfg: DateFormatConfig | undefined, +): cfg is DateFormatConfig { + return DATE_FORMAT_SHAPE_KEYS.some((key) => cfg?.[key] !== undefined); +} -/** Narrow a [`DateFormatConfig`] to its `Simple` arm, if it is one. */ -function simple_config( - cfg?: DateFormatConfig, -): SimpleDatetimeStyleConfig | undefined { - return cfg && !("format" in cfg) ? cfg : undefined; +/** The `date_format` options that apply individually. */ +export type DateFormatModifiers = Pick< + Intl.DateTimeFormatOptions, + "timeZone" | "hour12" +>; + +/** The `date_format` keys of `cfg` that apply individually, if it sets any. */ +export function dateFormatModifiers( + cfg: DateFormatConfig | undefined, +): DateFormatModifiers | undefined { + const out: DateFormatModifiers = {}; + if (cfg?.timeZone !== undefined) { + out.timeZone = cfg.timeZone; + } + + if (cfg?.hour12 !== undefined) { + out.hour12 = cfg.hour12; + } + + return Object.keys(out).length > 0 ? out : undefined; } /** @@ -71,18 +107,32 @@ const NUMERIC_LEGACY_DEFAULTS: Record = { }; /** - * Default `Intl.DateTimeFormatOptions` applied when a datetime column has - * no `date_format` configured. + * The viewer's own `date_format` for a datetime column, below every plugin + * and override layer. */ -const DATETIME_LEGACY_DEFAULTS: Intl.DateTimeFormatOptions = { +const DATETIME_BASE: DateFormatConfig = { dateStyle: "short", timeStyle: "medium", }; -const DATE_LEGACY_DEFAULTS: Intl.DateTimeFormatOptions = { +const DATE_BASE: DateFormatConfig & { dateStyle: "short" } = { dateStyle: "short", }; +const NUMBER_FORMAT_GROUPS: readonly (readonly string[])[] = [ + [ + "style", + "currency", + "currencyDisplay", + "currencySign", + "unit", + "unitDisplay", + ], + ["notation", "compactDisplay"], + ["minimumFractionDigits", "maximumFractionDigits"], + ["minimumSignificantDigits", "maximumSignificantDigits"], +]; + function mergeNumberOptions( base: Intl.NumberFormatOptions, cfg?: NumberFormatConfig, @@ -95,20 +145,12 @@ function mergeNumberOptions( } const merged: Record = { ...base }; - if ( - "minimumFractionDigits" in explicit || - "maximumFractionDigits" in explicit - ) { - delete merged.minimumFractionDigits; - delete merged.maximumFractionDigits; - } - - if ( - "minimumSignificantDigits" in explicit || - "maximumSignificantDigits" in explicit - ) { - delete merged.minimumSignificantDigits; - delete merged.maximumSignificantDigits; + for (const group of NUMBER_FORMAT_GROUPS) { + if (group.some((key) => key in explicit)) { + for (const key of group) { + delete merged[key]; + } + } } return Object.assign(merged, explicit) as Intl.NumberFormatOptions; @@ -136,90 +178,67 @@ export function createNumberFormatter( } /** - * `defaults` is the calling plugin's declared default `date_format`, - * applied only when `cfg` is wholly absent. + * `cfg` layered over `defaults`, then over `base`, by the `date_format` + * group rule, as `Intl.DateTimeFormat` options. + */ +export function resolveDateFormat( + cfg: DateFormatConfig | undefined, + defaults: DateFormatConfig | undefined, + base: DateFormatConfig, +): Intl.DateTimeFormatOptions { + const layers = [cfg, defaults, base]; + const shape: DateFormatConfig = layers.find(hasDateFormatShape) ?? {}; + const opts: DateFormatConfig = {}; + const copy = ( + key: K, + from: DateFormatConfig | undefined, + ) => { + if (from !== undefined && from[key] !== undefined) { + opts[key] = from[key]; + } + }; + + for (const key of DATE_FORMAT_SHAPE_KEYS) { + copy(key, shape); + } + + for (const key of DATE_FORMAT_MODIFIER_KEYS) { + copy( + key, + layers.find((layer) => layer?.[key] !== undefined), + ); + } + + return opts; +} + +/** + * `defaults` is the calling plugin's declared default `date_format`, which + * `cfg` overrides by the `date_format` group rule. */ export function createDatetimeFormatter( cfg?: DateFormatConfig, defaults?: DateFormatConfig, ): Intl.DateTimeFormat { - cfg = cfg ?? defaults; - if (!cfg || !("format" in cfg)) { - const preset = simple_config(cfg); - const opts: Intl.DateTimeFormatOptions = { - timeZone: preset?.timeZone ?? undefined, - dateStyle: - preset?.dateStyle === "disabled" - ? undefined - : (preset?.dateStyle ?? DATETIME_LEGACY_DEFAULTS.dateStyle), - timeStyle: - preset?.timeStyle === "disabled" - ? undefined - : (preset?.timeStyle ?? DATETIME_LEGACY_DEFAULTS.timeStyle), - }; - - return new Intl.DateTimeFormat(navigator.languages as string[], opts); - } - - // Per-part fields are the shared `CustomDatetimeFormat` enum; the - // editor only writes each part's `Intl`-valid subset, so the - // narrowing casts below are presentation-only. - const opts: Intl.DateTimeFormatOptions = { - timeZone: cfg.timeZone ?? undefined, - hour12: cfg.hour12 ?? true, - fractionalSecondDigits: cfg.fractionalSecondDigits as - | 1 - | 2 - | 3 - | undefined, - }; - if (cfg.year !== "disabled") { - opts.year = (cfg.year ?? - "2-digit") as Intl.DateTimeFormatOptions["year"]; - } - if (cfg.month !== "disabled") { - opts.month = (cfg.month ?? - "numeric") as Intl.DateTimeFormatOptions["month"]; - } - if (cfg.day !== "disabled") { - opts.day = (cfg.day ?? "numeric") as Intl.DateTimeFormatOptions["day"]; - } - if (cfg.weekday && cfg.weekday !== "disabled") { - opts.weekday = cfg.weekday as Intl.DateTimeFormatOptions["weekday"]; - } - if (cfg.hour !== "disabled") { - opts.hour = (cfg.hour ?? - "numeric") as Intl.DateTimeFormatOptions["hour"]; - } - if (cfg.minute !== "disabled") { - opts.minute = (cfg.minute ?? - "numeric") as Intl.DateTimeFormatOptions["minute"]; - } - if (cfg.second !== "disabled") { - opts.second = (cfg.second ?? - "numeric") as Intl.DateTimeFormatOptions["second"]; - } - return new Intl.DateTimeFormat(navigator.languages as string[], opts); + return new Intl.DateTimeFormat( + navigator.languages as string[], + resolveDateFormat(cfg, defaults, DATETIME_BASE), + ); } /** - * `defaults` follows the same contract as - * [`createDatetimeFormatter`]: the plugin's declared default - * `date_format`, applied only when `cfg` is wholly absent. + * `defaults` follows the same contract as [`createDatetimeFormatter`]; + * only `dateStyle` applies, in UTC. */ export function createDateFormatter( cfg?: DateFormatConfig, defaults?: DateFormatConfig, ): Intl.DateTimeFormat { - const preset = simple_config(cfg ?? defaults); - const opts: Intl.DateTimeFormatOptions = { + const { dateStyle } = resolveDateFormat(cfg, defaults, DATE_BASE); + return new Intl.DateTimeFormat(navigator.languages as string[], { timeZone: "utc", - dateStyle: - preset?.dateStyle === "disabled" - ? undefined - : (preset?.dateStyle ?? DATE_LEGACY_DEFAULTS.dateStyle), - }; - return new Intl.DateTimeFormat(navigator.languages as string[], opts); + dateStyle: dateStyle ?? DATE_BASE.dateStyle, + }); } /** diff --git a/rust/perspective-viewer/src/ts/extensions.ts b/rust/perspective-viewer/src/ts/extensions.ts index f6abe6cb2f..0605e1ed7a 100644 --- a/rust/perspective-viewer/src/ts/extensions.ts +++ b/rust/perspective-viewer/src/ts/extensions.ts @@ -14,7 +14,9 @@ import type { HTMLPerspectiveViewerPluginElement } from "./plugin"; import type { PerspectiveViewerElement } from "../../dist/wasm/perspective-viewer.js"; import type React from "react"; import type { ViewerConfigUpdate } from "./ts-rs/ViewerConfigUpdate.js"; -import type { ViewWindow, Filter } from "@perspective-dev/client"; +import type { ColumnType, ViewWindow, Filter } from "@perspective-dev/client"; +import type { ConfigDefaultsOptions } from "./ts-rs/ConfigDefaultsOptions.js"; +import type { ColumnConfigDefaultsOptions } from "./ts-rs/ColumnConfigDefaultsOptions.js"; import type { PerspectiveSelectDetail } from "./select-detail.ts"; export { PerspectiveSelectDetail } from "./select-detail.ts"; @@ -150,6 +152,73 @@ export interface PerspectiveViewerElementExt { */ registerPlugin(name: string): Promise; + /** + * Merge `defaults` into the `plugin_config` defaults every + * `` on the page applies. + * + * @category Plugin + * @param defaults Values keyed like `plugin_config`, where `null` removes + * a default, or `null` to clear the slot. + * @param options `plugin` limits the defaults to one plugin. + * @example + * ```javascript + * customElements + * .get("perspective-viewer") + * .setPluginConfigDefaults({ group_pad: 10 }); + * ``` + */ + setPluginConfigDefaults( + defaults: Record | null, + options?: ConfigDefaultsOptions, + ): void; + + /** + * Merge `defaults` into the `columns_config` defaults every + * `` on the page applies. + * + * @category Plugin + * @param defaults Values keyed like one `columns_config` entry, where + * `null` removes a default, or `null` to clear the slot. + * @param options `plugin` limits the defaults to one plugin, `type` to + * columns of one view type. + * @example + * ```javascript + * customElements + * .get("perspective-viewer") + * .setColumnConfigDefaults( + * { date_format: { timeZone: "UTC" } }, + * { type: "datetime" }, + * ); + * ``` + */ + setColumnConfigDefaults( + defaults: Record | null, + options?: ColumnConfigDefaultsOptions, + ): void; + + /** + * The `plugin_config` defaults set by `setPluginConfigDefaults()`, one + * entry per slot. + * + * @category Plugin + */ + getPluginConfigDefaults(): Array<{ + plugin?: string; + defaults: Record; + }>; + + /** + * The `columns_config` defaults set by `setColumnConfigDefaults()`, one + * entry per slot. + * + * @category Plugin + */ + getColumnConfigDefaults(): Array<{ + plugin?: string; + type?: ColumnType; + defaults: Record; + }>; + get_wasm_module(): WebAssembly.Module; get_worker_url(): URL; diff --git a/rust/perspective-viewer/test/html/debug_plugins.js b/rust/perspective-viewer/test/html/debug_plugins.js index 750b88e3e7..244165ed88 100644 --- a/rust/perspective-viewer/test/html/debug_plugins.js +++ b/rust/perspective-viewer/test/html/debug_plugins.js @@ -204,7 +204,100 @@ class DebugFormatPlugin extends DebugStyledPlugin { } else if (type === "date" || type === "datetime") { fields.push({ kind: "DatetimeFormat", - default: { dateStyle: "medium", timeStyle: "disabled" }, + default: { dateStyle: "medium" }, + }); + } + + return { fields }; + } +} + +class DebugDefaultsPlugin extends DebugStyledPlugin { + get_static_config() { + return { + name: "Debug Defaults", + select_mode: "toggle", + config_column_names: ["Columns"], + priority: 0, + can_render_column_styles: true, + }; + } + + plugin_config_schema() { + return { + fields: [ + { + kind: "Enum", + key: "edit_mode", + default: "READ_ONLY", + variants: [ + { value: "EDIT", label: "Edit" }, + { value: "READ_ONLY", label: "Read-only" }, + ], + }, + { + kind: "Number", + key: "group_pad", + default: 0, + min: 0, + max: 2, + step: 0.01, + }, + { kind: "Bool", key: "legend_on", default: false }, + ], + }; + } + + column_config_schema(type) { + const fields = [ + { + kind: "Font", + key: "font_family", + default: "monospace", + size: { key: "font_size", default: 12, min: 6, max: 32 }, + bold: { key: "font_bold", default: false }, + }, + { + kind: "Alignment", + key: "anchor", + default: "top-left", + corners: true, + }, + { kind: "String", key: "label", default: "" }, + { kind: "Color", key: "color", default: "#2771a8" }, + { + kind: "Palette", + key: "palette", + default: "linear-gradient(to right, #2771a8, #8b86ff, #ff471e)", + max: 6, + }, + { kind: "Number", key: "width", default: 10, min: 0, max: 100 }, + { + kind: "Enum", + key: "mode", + default: "none", + variants: [ + { value: "none", label: "None" }, + { value: "bar", label: "Bar" }, + ], + }, + { + kind: "Symbols", + default: { keys: "row", values: ["circle", "square"] }, + }, + { kind: "AggregateDepth" }, + ]; + + if (type === "integer" || type === "float") { + fields.push({ kind: "NumberFormat" }); + fields.push({ + kind: "NumberSeriesStyle", + default: { chart_type: "bar" }, + }); + } else if (type === "date" || type === "datetime") { + fields.push({ + kind: "DatetimeFormat", + default: { dateStyle: "medium" }, }); } @@ -215,11 +308,13 @@ class DebugFormatPlugin extends DebugStyledPlugin { customElements.define("perspective-viewer-debug-styled", DebugStyledPlugin); customElements.define("perspective-viewer-debug-alt", DebugAltPlugin); customElements.define("perspective-viewer-debug-format", DebugFormatPlugin); +customElements.define("perspective-viewer-debug-defaults", DebugDefaultsPlugin); const Viewer = customElements.get("perspective-viewer"); Viewer.registerPlugin("perspective-viewer-debug-styled"); Viewer.registerPlugin("perspective-viewer-debug-alt"); Viewer.registerPlugin("perspective-viewer-debug-format"); +Viewer.registerPlugin("perspective-viewer-debug-defaults"); async function load() { const resp = await fetch( diff --git a/rust/perspective-viewer/test/js/column_settings/config_defaults.spec.ts b/rust/perspective-viewer/test/js/column_settings/config_defaults.spec.ts new file mode 100644 index 0000000000..69014c05a1 --- /dev/null +++ b/rust/perspective-viewer/test/js/column_settings/config_defaults.spec.ts @@ -0,0 +1,179 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect, PageView } from "../helpers.ts"; +import type { Locator, Page } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/rust/perspective-viewer/test/html/superstore-debug.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +async function openStyleTab(view: PageView, name: string) { + const col = await view.settingsPanel.activeColumns.getColumnByName(name); + await view.assureColumnSettingsOpen(col); + await view.columnSettingsSidebar.container.waitFor({ state: "visible" }); +} + +async function column(view: PageView, name: string): Promise { + const col = await view.settingsPanel.activeColumns.getColumnByName(name); + return col.container; +} + +function field(view: PageView, label: string): Locator { + return view.columnSettingsSidebar.container.locator( + `#${label}-label + div`, + ); +} + +async function setColumnDefaults( + page: Page, + defaults: Record | null, + options?: Record, +) { + await page.evaluate( + async ({ defaults, options }) => { + const Viewer = customElements.get("perspective-viewer") as any; + Viewer.setColumnConfigDefaults(defaults, options); + await document.querySelector("perspective-viewer")!.flush(); + }, + { defaults, options }, + ); +} + +test.describe("Style tab under config defaults", () => { + test("shows an overridden default and stores nothing for it", async ({ + page, + }) => { + await setColumnDefaults( + page, + { mode: "bar" }, + { plugin: "Debug Defaults" }, + ); + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Defaults", + columns: ["Sales"], + }); + + await openStyleTab(view, "Sales"); + const mode = field(view, "mode").locator("select"); + await expect(mode).toHaveValue("bar"); + await expect(await column(view, "Sales")).not.toHaveClass( + /is-modified/, + ); + + await mode.selectOption("none"); + expect(((await view.save()) as any).columns_config).toEqual({ + Sales: { mode: "none" }, + }); + + await expect(await column(view, "Sales")).toHaveClass(/is-modified/); + + await mode.selectOption("bar"); + expect(((await view.save()) as any).columns_config).toEqual({}); + await expect(await column(view, "Sales")).not.toHaveClass( + /is-modified/, + ); + }); + + test("updates when a default is set while open", async ({ page }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Defaults", + columns: ["Sales"], + }); + + await openStyleTab(view, "Sales"); + const mode = field(view, "mode").locator("select"); + await expect(mode).toHaveValue("none"); + await setColumnDefaults(page, { mode: "bar" }); + await expect(mode).toHaveValue("bar"); + }); + + test("the timezone select shows a timezone default", async ({ page }) => { + await setColumnDefaults( + page, + { date_format: { timeZone: "Asia/Tokyo" } }, + { type: "datetime" }, + ); + + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Styled", + columns: ["Order Date"], + }); + + await openStyleTab(view, "Order Date"); + await expect(field(view, "timezone").locator("select")).toHaveValue( + "Asia/Tokyo", + ); + + expect(((await view.save()) as any).columns_config).toEqual({}); + }); + + test("a one-bound number default shows both bounds", async ({ page }) => { + await setColumnDefaults( + page, + { number_format: { maximumFractionDigits: 4 } }, + { type: "float" }, + ); + + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Styled", + columns: ["Sales"], + }); + + await openStyleTab(view, "Sales"); + const frac = field(view, "fractional-digits"); + await expect(frac.locator("input.parameter-min")).toHaveValue("2"); + await expect(frac.locator("input.parameter-max")).toHaveValue("4"); + expect(((await view.save()) as any).columns_config).toEqual({}); + }); +}); + +test.describe("Plugin tab under config defaults", () => { + test("shows an overridden default", async ({ page }) => { + await page.evaluate(() => { + const Viewer = customElements.get("perspective-viewer") as any; + Viewer.setPluginConfigDefaults({ edit_mode: "EDIT" }); + }); + + const view = new PageView(page); + await view.restore({ settings: true, plugin: "Debug Defaults" }); + await view.container.locator("#plugin_tabbar_tab").click(); + const tab = view.container.locator("#plugin-tab"); + await tab.waitFor({ state: "visible" }); + await expect(tab.locator("#edit_mode-label + div select")).toHaveValue( + "EDIT", + ); + + await page.evaluate(async () => { + const Viewer = customElements.get("perspective-viewer") as any; + Viewer.setPluginConfigDefaults({ edit_mode: null }); + await document.querySelector("perspective-viewer")!.flush(); + }); + + await expect(tab.locator("#edit_mode-label + div select")).toHaveValue( + "READ_ONLY", + ); + }); +}); diff --git a/rust/perspective-viewer/test/js/column_settings/datetime_format.spec.ts b/rust/perspective-viewer/test/js/column_settings/datetime_format.spec.ts new file mode 100644 index 0000000000..c00f01650c --- /dev/null +++ b/rust/perspective-viewer/test/js/column_settings/datetime_format.spec.ts @@ -0,0 +1,244 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect, PageView } from "../helpers.ts"; +import type { Locator } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/rust/perspective-viewer/test/html/superstore-debug.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +async function openStyleTab(view: PageView, name: string) { + const col = await view.settingsPanel.activeColumns.getColumnByName(name); + await view.assureColumnSettingsOpen(col); + await view.columnSettingsSidebar.container.waitFor({ state: "visible" }); +} + +function select(view: PageView, label: string): Locator { + return view.columnSettingsSidebar.container.locator( + `#${label}-label + div select`, + ); +} + +async function dateFormat(view: PageView) { + const config = (await view.save()) as any; + return config.columns_config["Order Date"]?.date_format; +} + +test.describe("date_format editing", () => { + test("a display field edit stores the whole shape", async ({ page }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Format", + columns: ["Order Date"], + }); + + await openStyleTab(view, "Order Date"); + await expect(select(view, "time-style")).toHaveValue("disabled"); + await select(view, "time-style").selectOption("short"); + expect(await dateFormat(view)).toEqual({ + dateStyle: "medium", + timeStyle: "short", + }); + }); + + test("a timezone edit stores only the timezone", async ({ page }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Styled", + columns: ["Order Date"], + }); + + await openStyleTab(view, "Order Date"); + await select(view, "timezone").selectOption("Asia/Tokyo"); + expect(await dateFormat(view)).toEqual({ timeZone: "Asia/Tokyo" }); + }); + + test("hiding a field stores the shape without it", async ({ page }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Styled", + columns: ["Order Date"], + }); + + await openStyleTab(view, "Order Date"); + await select(view, "time-style").selectOption("disabled"); + expect(await dateFormat(view)).toEqual({ dateStyle: "short" }); + }); + + test("returning to the inherited shape stores nothing", async ({ + page, + }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Styled", + columns: ["Order Date"], + }); + + await openStyleTab(view, "Order Date"); + await select(view, "date-style").selectOption("long"); + await select(view, "date-style").selectOption("short"); + expect(((await view.save()) as any).columns_config).toEqual({}); + }); + + test("the last shown display field cannot be hidden", async ({ page }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Format", + columns: ["Order Date"], + }); + + await openStyleTab(view, "Order Date"); + const hidden = 'option[value="disabled"]'; + await expect(select(view, "date-style").locator(hidden)).toHaveCount(0); + await select(view, "time-style").selectOption("short"); + await expect(select(view, "date-style").locator(hidden)).toHaveCount(1); + await expect(select(view, "time-style").locator(hidden)).toHaveCount(1); + }); + + test("switching modes writes the other mode's shape", async ({ page }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Styled", + columns: ["Order Date"], + }); + + await openStyleTab(view, "Order Date"); + const toggle = + view.columnSettingsSidebar.container.locator("#datetime_format"); + + await toggle.click(); + expect(await dateFormat(view)).toEqual({ + year: "2-digit", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + + await toggle.click(); + expect(((await view.save()) as any).columns_config).toEqual({}); + }); + + test("successive per-part edits accumulate", async ({ page }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Styled", + columns: ["Order Date"], + }); + + await openStyleTab(view, "Order Date"); + await view.columnSettingsSidebar.container + .locator("#datetime_format") + .click(); + + await select(view, "year").selectOption("numeric"); + await select(view, "month").selectOption("long"); + await select(view, "second").selectOption("disabled"); + expect(await dateFormat(view)).toEqual({ + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "numeric", + }); + + await expect(select(view, "year")).toHaveValue("numeric"); + }); + + test("successive preset edits accumulate", async ({ page }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Debug Styled", + columns: ["Order Date"], + }); + + await openStyleTab(view, "Order Date"); + await select(view, "date-style").selectOption("long"); + await select(view, "time-style").selectOption("short"); + expect(await dateFormat(view)).toEqual({ + dateStyle: "long", + timeStyle: "short", + }); + }); + + test("restore rejects a value mixing presets and parts", async ({ + page, + }) => { + const view = new PageView(page); + await view.restore({ plugin: "Debug Styled", columns: ["Order Date"] }); + const error = await page.evaluate(async () => { + try { + await document.querySelector("perspective-viewer")!.restore({ + columns_config: { + "Order Date": { + date_format: { dateStyle: "long", year: "numeric" }, + }, + }, + } as any); + return null; + } catch (e: any) { + return e.message ?? String(e); + } + }); + + expect(error).toMatch(/Order Date.*date_format.*cannot be combined/); + }); + + test("restore rejects an unknown date_format key", async ({ page }) => { + const view = new PageView(page); + await view.restore({ plugin: "Debug Styled", columns: ["Order Date"] }); + const error = await page.evaluate(async () => { + try { + await document.querySelector("perspective-viewer")!.restore({ + columns_config: { + "Order Date": { date_format: { date_style: "long" } }, + }, + } as any); + return null; + } catch (e: any) { + return e.message ?? String(e); + } + }); + + expect(error).toMatch(/unknown field `date_style`/); + }); + + test("a restored value equal to the inherited one is stripped", async ({ + page, + }) => { + const view = new PageView(page); + await view.restore({ + plugin: "Debug Format", + columns: ["Order Date"], + columns_config: { + "Order Date": { date_format: { dateStyle: "medium" } }, + }, + }); + + expect(((await view.save()) as any).columns_config).toEqual({}); + }); +}); diff --git a/rust/perspective-viewer/test/js/column_settings/format_defaults.spec.ts b/rust/perspective-viewer/test/js/column_settings/format_defaults.spec.ts index 0bcdb0c1ad..e02a334304 100644 --- a/rust/perspective-viewer/test/js/column_settings/format_defaults.spec.ts +++ b/rust/perspective-viewer/test/js/column_settings/format_defaults.spec.ts @@ -216,6 +216,7 @@ test.describe("Datetime format defaults", () => { const config = (await view.save()) as any; expect(config.columns_config["Order Date"].date_format).toEqual({ dateStyle: "medium", + timeStyle: "medium", }); }); @@ -234,7 +235,9 @@ test.describe("Datetime format defaults", () => { await expect(dateStyle.locator("select")).toHaveValue("medium"); }); - test("legacy preset serializes under an override", async ({ page }) => { + test("a preset other than the declared one stores the whole shape", async ({ + page, + }) => { const view = new PageView(page); await view.restore({ settings: true, @@ -248,7 +251,7 @@ test.describe("Datetime format defaults", () => { const config = (await view.save()) as any; expect(config.columns_config["Order Date"].date_format).toEqual({ - timeStyle: "disabled", + dateStyle: "short", }); }); }); diff --git a/rust/perspective-viewer/test/js/viewer_api/config_defaults.spec.ts b/rust/perspective-viewer/test/js/viewer_api/config_defaults.spec.ts new file mode 100644 index 0000000000..fbe1c9df1f --- /dev/null +++ b/rust/perspective-viewer/test/js/viewer_api/config_defaults.spec.ts @@ -0,0 +1,394 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect, PageView } from "../helpers.ts"; +import type { Page } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/rust/perspective-viewer/test/html/superstore-debug.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +async function delivered( + page: Page, + tag = "perspective-viewer-debug-defaults", +) { + return await page.evaluate(async (tag) => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.flush(); + const plugin = viewer.querySelector(tag) as any; + return { + plugin_config: plugin._restored_plugin_config, + columns_config: plugin._restored_columns_config, + restore_count: plugin._restore_count, + }; + }, tag); +} + +async function setDefaults( + page: Page, + kind: "plugin" | "column", + defaults: Record | null, + options?: Record, +): Promise { + return await page.evaluate( + ({ kind, defaults, options }) => { + const Viewer = customElements.get("perspective-viewer") as any; + try { + if (kind === "plugin") { + Viewer.setPluginConfigDefaults(defaults, options); + } else { + Viewer.setColumnConfigDefaults(defaults, options); + } + + return null; + } catch (e: any) { + return e.message ?? String(e); + } + }, + { kind, defaults, options }, + ); +} + +async function snapshot(page: Page) { + return await page.evaluate(() => { + const Viewer = customElements.get("perspective-viewer") as any; + return { + plugin: Viewer.getPluginConfigDefaults(), + column: Viewer.getColumnConfigDefaults(), + }; + }); +} + +test.describe("Config defaults registry", () => { + test("calls merge by key and null removes", async ({ page }) => { + await setDefaults(page, "plugin", { group_pad: 0.5 }); + await setDefaults(page, "plugin", { legend_on: true }); + expect((await snapshot(page)).plugin).toEqual([ + { defaults: { group_pad: 0.5, legend_on: true } }, + ]); + + await setDefaults(page, "plugin", { group_pad: null }); + expect((await snapshot(page)).plugin).toEqual([ + { defaults: { legend_on: true } }, + ]); + + await setDefaults(page, "plugin", null); + expect((await snapshot(page)).plugin).toEqual([]); + }); + + test("a nested null removes one sub-key and an emptied object", async ({ + page, + }) => { + await setDefaults( + page, + "column", + { date_format: { timeZone: "Asia/Tokyo", hour12: false } }, + { type: "datetime" }, + ); + + await setDefaults( + page, + "column", + { date_format: { timeZone: null } }, + { type: "datetime" }, + ); + + expect((await snapshot(page)).column).toEqual([ + { type: "datetime", defaults: { date_format: { hour12: false } } }, + ]); + + await setDefaults( + page, + "column", + { date_format: { hour12: null } }, + { type: "datetime" }, + ); + + expect((await snapshot(page)).column).toEqual([]); + }); + + test("plugin names normalize to the registered name", async ({ page }) => { + await setDefaults( + page, + "plugin", + { group_pad: 1 }, + { plugin: "debugdefaults" }, + ); + expect((await snapshot(page)).plugin).toEqual([ + { plugin: "Debug Defaults", defaults: { group_pad: 1 } }, + ]); + }); + + test("call-time rejections throw and change nothing", async ({ page }) => { + expect( + await setDefaults( + page, + "plugin", + { group_pad: 1 }, + { plugin: "Nope" }, + ), + ).toMatch(/No plugin named `Nope`/); + + expect( + await setDefaults(page, "column", { width: 1 }, { type: "money" }), + ).toMatch(/`type` must be one of/); + + expect(await setDefaults(page, "plugin", 5 as any)).toMatch( + /must be an object or `null`/, + ); + + expect( + await setDefaults( + page, + "column", + { date_format: { dateStyle: "long", year: "numeric" } }, + { type: "datetime" }, + ), + ).toMatch(/cannot be combined/); + + expect( + await setDefaults( + page, + "column", + { number_format: 4 }, + { type: "float" }, + ), + ).toMatch(/must be an object or `null`/); + + expect(await snapshot(page)).toEqual({ plugin: [], column: [] }); + }); +}); + +test.describe("Config defaults delivery", () => { + test("an unqualified plugin default is delivered and never saved", async ({ + page, + }) => { + await setDefaults(page, "plugin", { group_pad: 0.5 }); + const view = new PageView(page); + await view.restore({ plugin: "Debug Defaults", columns: ["Sales"] }); + expect((await delivered(page)).plugin_config.group_pad).toBe(0.5); + expect(((await view.save()) as any).plugin_config).toEqual({}); + }); + + test("a plugin-qualified default applies to that plugin only", async ({ + page, + }) => { + await setDefaults( + page, + "plugin", + { edit_mode: "EDIT" }, + { plugin: "Debug Defaults" }, + ); + + const view = new PageView(page); + await view.restore({ plugin: "Debug Defaults", columns: ["Sales"] }); + expect((await delivered(page)).plugin_config.edit_mode).toBe("EDIT"); + + await view.restore({ plugin: "Debug Styled" }); + const styled = await delivered(page, "perspective-viewer-debug-styled"); + expect(styled.plugin_config.edit_mode).toBeUndefined(); + }); + + test("column defaults resolve by precedence", async ({ page }) => { + await setDefaults(page, "column", { width: 20 }); + await setDefaults(page, "column", { width: 30 }, { type: "float" }); + await setDefaults( + page, + "column", + { width: 40 }, + { plugin: "Debug Defaults" }, + ); + await setDefaults( + page, + "column", + { width: 50 }, + { plugin: "Debug Defaults", type: "float" }, + ); + + const view = new PageView(page); + await view.restore({ plugin: "Debug Defaults", columns: ["Sales"] }); + expect((await delivered(page)).columns_config.Sales.width).toBe(50); + + await setDefaults(page, "column", null, { + plugin: "Debug Defaults", + type: "float", + }); + + expect((await delivered(page)).columns_config.Sales.width).toBe(40); + + await setDefaults(page, "column", null, { plugin: "Debug Defaults" }); + expect((await delivered(page)).columns_config.Sales.width).toBe(30); + + await setDefaults(page, "column", null, { type: "float" }); + expect((await delivered(page)).columns_config.Sales.width).toBe(20); + }); + + test("column defaults reach columns with and without stored entries", async ({ + page, + }) => { + await setDefaults(page, "column", { width: 20 }); + const view = new PageView(page); + await view.restore({ + plugin: "Debug Defaults", + columns: ["Sales", "Profit"], + columns_config: { Profit: { color: "#ff0000" } }, + }); + + const { columns_config } = await delivered(page); + expect(columns_config.Sales).toEqual({ width: 20 }); + expect(columns_config.Profit).toEqual({ color: "#ff0000", width: 20 }); + expect(((await view.save()) as any).columns_config).toEqual({ + Profit: { color: "#ff0000" }, + }); + }); + + test("a change after render re-delivers on flush without a config update", async ({ + page, + }) => { + const view = new PageView(page); + await view.restore({ plugin: "Debug Defaults", columns: ["Sales"] }); + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.flush(); + (window as any).__config_updates__ = 0; + viewer.addEventListener("perspective-config-update", () => { + (window as any).__config_updates__++; + }); + }); + + await setDefaults(page, "plugin", { group_pad: 1 }); + expect((await delivered(page)).plugin_config.group_pad).toBe(1); + expect( + await page.evaluate(() => (window as any).__config_updates__), + ).toBe(0); + }); + + test("a call that changes nothing does not re-deliver", async ({ + page, + }) => { + const view = new PageView(page); + await view.restore({ plugin: "Debug Defaults", columns: ["Sales"] }); + await setDefaults(page, "plugin", { group_pad: 1 }); + const before = (await delivered(page)).restore_count; + await setDefaults(page, "plugin", { group_pad: 1 }); + await setDefaults(page, "plugin", {}); + await setDefaults(page, "column", null, { type: "float" }); + expect((await delivered(page)).restore_count).toBe(before); + }); + + test("panels and elements created after a call apply it", async ({ + page, + }) => { + await setDefaults(page, "plugin", { group_pad: 1 }); + const pads = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Debug Defaults", + columns: ["Sales"], + }); + await viewer.addPanel({ + table: "load-viewer-csv", + plugin: "Debug Defaults", + columns: ["Profit"], + } as any); + + const second = document.createElement("perspective-viewer"); + document.body.appendChild(second); + await second.load((window as any).__TEST_WORKER__); + await second.restore({ + table: "load-viewer-csv", + plugin: "Debug Defaults", + columns: ["Sales"], + }); + + await viewer.flush(); + await second.flush(); + return Array.from( + document.querySelectorAll("perspective-viewer-debug-defaults"), + ).map((x: any) => x._restored_plugin_config?.group_pad); + }); + + expect(pads.length).toBe(3); + expect(pads.every((x) => x === 1)).toBe(true); + }); + + test("a restore before the call loses values equal to the plugin default", async ({ + page, + }) => { + const view = new PageView(page); + await view.restore({ + plugin: "Debug Defaults", + columns: ["Sales"], + plugin_config: { group_pad: 0 }, + }); + + await setDefaults(page, "plugin", { group_pad: 1 }); + expect((await delivered(page)).plugin_config.group_pad).toBe(1); + expect(((await view.save()) as any).plugin_config).toEqual({}); + }); + + test("a restore after the call keeps values equal to the plugin default", async ({ + page, + }) => { + await setDefaults(page, "plugin", { group_pad: 1 }); + const view = new PageView(page); + await view.restore({ + plugin: "Debug Defaults", + columns: ["Sales"], + plugin_config: { group_pad: 0 }, + }); + + expect((await delivered(page)).plugin_config.group_pad).toBe(0); + expect(((await view.save()) as any).plugin_config).toEqual({ + group_pad: 0, + }); + }); + + test("invalid defaults are reported and leave the plugin default", async ({ + page, + consoleLogs, + }) => { + for (const key of [ + "mode", + "anchor", + "width", + "color", + "symbols", + "aggregate_depth", + ]) { + consoleLogs.expectedLogs.push( + "error", + new RegExp( + `Ignoring default override \`${key}\` for plugin \`Debug Defaults\` column \`Sales\``, + ), + ); + } + + await setDefaults(page, "column", { + mode: "nope", + anchor: "center", + width: 500, + color: "not-a-color", + symbols: { East: "circle" }, + aggregate_depth: 1, + }); + + const view = new PageView(page); + await view.restore({ plugin: "Debug Defaults", columns: ["Sales"] }); + expect((await delivered(page)).columns_config.Sales).toBeUndefined(); + }); +}); diff --git a/rust/perspective-viewer/test/js/viewer_config/date_format_migration.spec.ts b/rust/perspective-viewer/test/js/viewer_config/date_format_migration.spec.ts new file mode 100644 index 0000000000..299aec2d07 --- /dev/null +++ b/rust/perspective-viewer/test/js/viewer_config/date_format_migration.spec.ts @@ -0,0 +1,120 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect, PageView, API_VERSION } from "../helpers.ts"; + +test.beforeEach(async ({ page }) => { + await page.goto("/rust/perspective-viewer/test/html/superstore-debug.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +async function restoredDateFormat( + view: PageView, + plugin: string, + date_format: Record, + version?: string, +) { + await view.restore({ + plugin, + columns: ["Order Date"], + columns_config: { "Order Date": { date_format } }, + ...(version ? { version } : {}), + } as any); + + const config = (await view.save()) as any; + return config.columns_config["Order Date"]?.date_format; +} + +test.describe("date_format migration", () => { + test("a per-part value gains its old fallbacks", async ({ page }) => { + const view = new PageView(page); + expect( + await restoredDateFormat(view, "Debug Styled", { + format: "custom", + timeZone: "UTC", + second: "disabled", + minute: "disabled", + hour: "disabled", + }), + ).toEqual({ + timeZone: "UTC", + year: "2-digit", + month: "numeric", + day: "numeric", + hour12: true, + }); + }); + + test("a preset with a disabled field keeps its rendering", async ({ + page, + }) => { + const view = new PageView(page); + expect( + await restoredDateFormat(view, "Debug Format", { + timeStyle: "disabled", + }), + ).toEqual({ dateStyle: "short" }); + }); + + test("a preset with both fields disabled renders a numeric date", async ({ + page, + }) => { + const view = new PageView(page); + expect( + await restoredDateFormat(view, "Debug Styled", { + dateStyle: "disabled", + timeStyle: "disabled", + }), + ).toEqual({ year: "numeric", month: "numeric", day: "numeric" }); + }); + + test("an older versioned preset gains its missing style", async ({ + page, + }) => { + const view = new PageView(page); + expect( + await restoredDateFormat( + view, + "Debug Format", + { dateStyle: "long" }, + "5.5.0", + ), + ).toEqual({ dateStyle: "long", timeStyle: "medium" }); + }); + + test("an unversioned preset takes the inheriting meaning", async ({ + page, + }) => { + const view = new PageView(page); + expect( + await restoredDateFormat(view, "Debug Format", { + dateStyle: "long", + }), + ).toEqual({ dateStyle: "long" }); + }); + + test("a preset saved by this build is not migrated", async ({ page }) => { + const view = new PageView(page); + expect( + await restoredDateFormat( + view, + "Debug Format", + { dateStyle: "long" }, + API_VERSION, + ), + ).toEqual({ dateStyle: "long" }); + }); +});