Skip to content

Commit 4345046

Browse files
committed
[IMP] add date picker for date scale axis
This commit extends the axis customization to date type x scale closes #7350 Task: 5159370 Signed-off-by: Rémi Rahir (rar) <rar@odoo.com>
1 parent 99c54e6 commit 4345046

File tree

11 files changed

+242
-46
lines changed

11 files changed

+242
-46
lines changed

rollup.config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,14 @@ export default (commandLineArgs) => {
6565
// Only build one version to improve speed
6666
config = {
6767
input: "build/js/src/index.js",
68-
external: ["@odoo/owl", "chart.js"],
68+
external: ["@odoo/owl", "chart.js", "luxon"],
6969
output: [
7070
{
7171
name: "o_spreadsheet",
7272
extend: true,
7373
outro,
7474
banner: bundle.jsBanner(),
75-
globals: { "@odoo/owl": "owl", "chart.js": "Chart" },
75+
globals: { "@odoo/owl": "owl", "chart.js": "Chart", luxon: "luxon" },
7676
file: `build/o_spreadsheet.${extension}`,
7777
format: commandLineArgs.format,
7878
},
@@ -118,7 +118,7 @@ export default (commandLineArgs) => {
118118
config = [
119119
{
120120
input,
121-
external: ["@odoo/owl", "chart.js"],
121+
external: ["@odoo/owl", "chart.js", "luxon"],
122122
output,
123123
plugins,
124124
},
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { NumberInput } from "../number_input/number_input";
2+
3+
export class DateInput extends NumberInput {
4+
static template = "o-spreadsheet-DateInput";
5+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<templates>
2+
<t t-name="o-spreadsheet-DateInput">
3+
<input
4+
type="date"
5+
t-ref="{{refName}}"
6+
t-att-class="inputClass"
7+
t-att-id="props.id"
8+
t-att-placeholder="props.placeholder"
9+
t-on-change="save"
10+
t-on-blur="save"
11+
t-on-pointerdown="onMouseDown"
12+
t-on-pointerup="onMouseUp"
13+
t-on-keydown="onKeyDown"
14+
t-att-min="props.min"
15+
t-att-max="props.max"
16+
/>
17+
</t>
18+
</templates>

src/components/figures/chart/chartJs/chartjs_minor_grid_plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Chart } from "chart.js";
1+
import type { Chart } from "chart.js";
22
import { Color } from "../../../..";
33

44
interface MinorGridOptions {

src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.ts

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { _t } from "@odoo/o-spreadsheet-engine";
22
import { CHART_AXIS_TITLE_FONT_SIZE } from "@odoo/o-spreadsheet-engine/constants";
3-
import { AxisGridType, AxisId, LineChartRuntime } from "@odoo/o-spreadsheet-engine/types/chart";
3+
import { toNumber } from "@odoo/o-spreadsheet-engine/functions/helpers";
4+
import {
5+
AxisGridType,
6+
AxisId,
7+
AxisType,
8+
LineChartRuntime,
9+
} from "@odoo/o-spreadsheet-engine/types/chart";
410
import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env";
511
import { Component, useState } from "@odoo/owl";
6-
import { deepCopy } from "../../../../../helpers";
12+
import { deepCopy, formatValue } from "../../../../../helpers";
713
import { getDefinedAxis } from "../../../../../helpers/figures/charts";
814
import {
915
AxisDesign,
@@ -12,6 +18,7 @@ import {
1218
TitleDesign,
1319
UID,
1420
} from "../../../../../types";
21+
import { DateInput } from "../../../../date_input/date_input";
1522
import { NumberInput } from "../../../../number_input/number_input";
1623
import { BadgeSelection } from "../../../components/badge_selection/badge_selection";
1724
import { Checkbox } from "../../../components/checkbox/checkbox";
@@ -32,7 +39,7 @@ interface Props {
3239

3340
export class AxisDesignEditor extends Component<Props, SpreadsheetChildEnv> {
3441
static template = "o-spreadsheet-AxisDesignEditor";
35-
static components = { Section, ChartTitle, BadgeSelection, Checkbox, NumberInput };
42+
static components = { Section, ChartTitle, BadgeSelection, Checkbox, NumberInput, DateInput };
3643
static props = { chartId: String, definition: Object, updateChart: Function, axesList: Array };
3744

3845
state: { currentAxis: AxisId } = useState({ currentAxis: "x" });
@@ -73,12 +80,14 @@ export class AxisDesignEditor extends Component<Props, SpreadsheetChildEnv> {
7380
this.props.updateChart(this.props.chartId, { axesDesign });
7481
}
7582

76-
get axisMin(): number | string {
77-
return this.currentAxisDesign?.min ?? "";
83+
get axisMin(): string | number {
84+
const min = this.currentAxisDesign?.min;
85+
return (this.isTimeAxis ? this.formatAxisBoundary(min) : min) ?? "";
7886
}
7987

80-
get axisMax(): number | string {
81-
return this.currentAxisDesign?.max ?? "";
88+
get axisMax(): string | number {
89+
const max = this.currentAxisDesign?.max;
90+
return (this.isTimeAxis ? this.formatAxisBoundary(max) : max) ?? "";
8291
}
8392

8493
get isMajorGridEnabled(): boolean {
@@ -115,29 +124,55 @@ export class AxisDesignEditor extends Component<Props, SpreadsheetChildEnv> {
115124
}
116125

117126
updateAxisMin(value: string) {
118-
const parsed = value === "" ? undefined : Number(value);
119-
if (parsed === undefined || !isNaN(parsed)) {
127+
const min = value === "" ? undefined : Number(value);
128+
if (min === undefined || !isNaN(min)) {
120129
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
121130
axesDesign[this.state.currentAxis] = {
122131
...axesDesign[this.state.currentAxis],
123-
min: parsed,
132+
min,
124133
};
125134
this.props.updateChart(this.props.chartId, { axesDesign });
126135
}
127136
}
128137

138+
updateTimeAxisMin(value: string) {
139+
const min = this.parseTimeAxisBoundaryValue(value);
140+
if (min === null) {
141+
return;
142+
}
143+
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
144+
axesDesign[this.state.currentAxis] = {
145+
...axesDesign[this.state.currentAxis],
146+
min,
147+
};
148+
this.props.updateChart(this.props.chartId, { axesDesign });
149+
}
150+
129151
updateAxisMax(value: string) {
130-
const parsed = value === "" ? undefined : Number(value);
131-
if (parsed === undefined || !isNaN(parsed)) {
152+
const max = value === "" ? undefined : Number(value);
153+
if (max === undefined || !isNaN(max)) {
132154
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
133155
axesDesign[this.state.currentAxis] = {
134156
...axesDesign[this.state.currentAxis],
135-
max: parsed,
157+
max,
136158
};
137159
this.props.updateChart(this.props.chartId, { axesDesign });
138160
}
139161
}
140162

163+
updateTimeAxisMax(value: string) {
164+
const max = this.parseTimeAxisBoundaryValue(value);
165+
if (max === null) {
166+
return;
167+
}
168+
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
169+
axesDesign[this.state.currentAxis] = {
170+
...axesDesign[this.state.currentAxis],
171+
max,
172+
};
173+
this.props.updateChart(this.props.chartId, { axesDesign });
174+
}
175+
141176
toggleMajorGrid(major: boolean) {
142177
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
143178
let gridLines: AxisGridType = "none";
@@ -193,8 +228,39 @@ export class AxisDesignEditor extends Component<Props, SpreadsheetChildEnv> {
193228
if (this.isValueAxis) {
194229
return false;
195230
}
231+
const axisType = this.getXAxisType();
232+
return axisType === undefined || axisType === "category";
233+
}
234+
235+
get isTimeAxis(): boolean {
236+
return this.state.currentAxis === "x" && this.getXAxisType() === "time";
237+
}
238+
239+
get canChangeMinorGridVisibility(): boolean {
240+
if (this.isValueAxis) {
241+
return true;
242+
}
243+
if (this.isCategoricalAxis) {
244+
return false;
245+
}
246+
const type = this.props.definition.type;
247+
return type === "line" || type === "scatter";
248+
}
249+
250+
private parseTimeAxisBoundaryValue(value: string): number | undefined | null {
251+
const dateNumber = toNumber(value, this.env.model.getters.getLocale());
252+
return Number.isNaN(dateNumber) ? null : dateNumber;
253+
}
254+
255+
private formatAxisBoundary(value: number | undefined): string | undefined {
256+
if (value === undefined) {
257+
return undefined;
258+
}
259+
return formatValue(value, { format: "yyyy-mm-dd", locale: this.env.model.getters.getLocale() });
260+
}
261+
262+
private getXAxisType(): AxisType | undefined {
196263
const runtime = this.env.model.getters.getChartRuntime(this.props.chartId) as LineChartRuntime;
197-
const axisType = runtime?.chartJsConfig.options?.scales?.x?.type;
198-
return axisType === undefined || axisType === "time";
264+
return runtime?.chartJsConfig.options?.scales?.x?.type as AxisType | undefined;
199265
}
200266
}

src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@
2424
<div class="d-flex">
2525
<div class="w-50">
2626
<span class="o-section-subtitle my-0">Minimum</span>
27+
<DateInput
28+
t-if="isTimeAxis"
29+
class="'o-input w-100 time-axis-min-input'"
30+
value="axisMin"
31+
onChange.bind="updateTimeAxisMin"
32+
/>
2733
<NumberInput
34+
t-else=""
2835
class="'o-input w-100 axis-min-input'"
2936
value="axisMin"
3037
onChange.bind="updateAxisMin"
@@ -33,7 +40,14 @@
3340
</div>
3441
<div class="w-50 ms-3">
3542
<span class="o-section-subtitle my-0">Maximum</span>
43+
<DateInput
44+
t-if="isTimeAxis"
45+
class="'o-input w-100 time-axis-max-input'"
46+
value="axisMax"
47+
onChange.bind="updateTimeAxisMax"
48+
/>
3649
<NumberInput
50+
t-else=""
3751
class="'o-input w-100 axis-max-input'"
3852
value="axisMax"
3953
onChange.bind="updateAxisMax"

src/components/spreadsheet/spreadsheet.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@
158158
}
159159

160160
.o-input[type="number"],
161+
.o-input[type="date"],
161162
.o-number-input {
162163
border-width: 0 0 1px 0;
163164
/* Remove number input arrows */

src/helpers/chart_date.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { TimeScaleOptions } from "chart.js";
22
import { DeepPartial } from "chart.js/dist/types/utils";
3-
import { largeMax, largeMin, parseDateTime } from ".";
3+
import { DateTime } from "luxon";
4+
import { largeMax, largeMin } from ".";
45
import { Alias, Format, Locale } from "../types";
56

67
// -----------------------------------------------------------------------------
@@ -44,7 +45,7 @@ const Milliseconds = {
4445
* Regex to test if a format string is a date format that can be translated into a luxon time format
4546
*/
4647
export const timeFormatLuxonCompatible =
47-
/^((d|dd|m|mm|yyyy|yy|hh|h|ss|a)(-|:|\s|\/))*(d|dd|m|mm|yyyy|yy|hh|h|ss|a)$/i;
48+
/^((d|dd|m|mm|mmm|yyyy|yy|hh|h|ss|a)(-|:|\s|\/))*(d|dd|m|mm|mmm|yyyy|yy|hh|h|ss|a)$/i;
4849

4950
/** Get the time options for the XAxis of ChartJS */
5051
export function getChartTimeOptions(
@@ -73,7 +74,7 @@ export function getChartTimeOptions(
7374
*
7475
* https://github.com/moment/luxon/blob/master/docs/formatting.md#table-of-tokens
7576
*/
76-
function convertDateFormatForLuxon(format: Format): LuxonFormat {
77+
export function convertDateFormatForLuxon(format: Format): LuxonFormat {
7778
// "m" before "h" === month, "m" after "h" === minute
7879
const indexH = format.indexOf("h");
7980
if (indexH >= 0) {
@@ -122,11 +123,11 @@ function getBestTimeUnitForScale(
122123
format: LuxonFormat,
123124
locale: Locale
124125
): TimeUnit | undefined {
125-
const labelDates = labels.map((label) => parseDateTime(label, locale)?.jsDate);
126+
const labelDates = labels.map((label) => DateTime.fromFormat(label, format));
126127
if (labelDates.some((date) => date === undefined) || labels.length < 2) {
127128
return undefined;
128129
}
129-
const labelsTimestamps = labelDates.map((date) => date!.getTime());
130+
const labelsTimestamps = labelDates.map((date) => date!.ts);
130131
const period = largeMax(labelsTimestamps) - largeMin(labelsTimestamps);
131132

132133
const minUnit = getFormatMinDisplayUnit(format);

src/helpers/figures/charts/runtime/chartjs_scales.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ export function getLineChartScales(
203203
const axis = {
204204
type: "time",
205205
time: getChartTimeOptions(labels, labelFormat, locale),
206+
min: formatValue(definition.axesDesign?.x?.min ?? "", { format: labelFormat, locale }),
207+
max: formatValue(definition.axesDesign?.x?.max ?? "", {
208+
format: labelFormat,
209+
locale,
210+
}),
206211
};
207212
Object.assign(scales!.x!, axis);
208213
scales!.x!.ticks!.maxTicksLimit = 15;

tests/figures/chart/chart_plugin.test.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { ChartPlugin } from "@odoo/o-spreadsheet-engine/plugins/core/chart";
6161
import { FigurePlugin } from "@odoo/o-spreadsheet-engine/plugins/core/figures";
6262
import { ScatterChartRuntime } from "@odoo/o-spreadsheet-engine/types/chart/scatter_chart";
6363
import { zoneToXc } from "../../../src/helpers";
64+
import { convertDateFormatForLuxon } from "../../../src/helpers/chart_date";
6465
import { BarChart } from "../../../src/helpers/figures/charts";
6566
import {
6667
getCategoryAxisTickLabels,
@@ -2796,28 +2797,32 @@ describe("Linear/Time charts", () => {
27962797
expect(getChartConfiguration(model, chartId).options?.scales?.x?.type).toEqual("linear");
27972798
});
27982799

2799-
test("time axis for line/bar chart with date labels", () => {
2800-
setFormat(model, "C2:C5", "m/d/yyyy");
2801-
createChart(
2802-
model,
2803-
{
2804-
type: "line",
2805-
dataSets: [{ dataRange: "B2:B5" }],
2806-
labelRange: "C2:C5",
2807-
labelsAsText: false,
2808-
},
2809-
chartId
2810-
);
2811-
const scale = getChartConfiguration(model, chartId).options.scales.x;
2812-
expect(scale.type).toEqual("time");
2813-
expect(scale.ticks?.callback).toBeUndefined();
2814-
expect(scale.time).toEqual({
2815-
displayFormats: { day: "M/d/yyyy" }, // luxon format
2816-
parser: "M/d/yyyy",
2817-
tooltipFormat: "M/d/yyyy",
2818-
unit: "day",
2819-
});
2820-
});
2800+
test.each(["mm/dd/yyyy", "yyyy-mm-dd", "dd/mm/yyyy", "d mmm yyyy"])(
2801+
"time axis for line/bar chart with date labels in format %s",
2802+
(format: string) => {
2803+
setFormat(model, "C2:C5", format);
2804+
createChart(
2805+
model,
2806+
{
2807+
type: "line",
2808+
dataSets: [{ dataRange: "B2:B5" }],
2809+
labelRange: "C2:C5",
2810+
labelsAsText: false,
2811+
},
2812+
chartId
2813+
);
2814+
const convertedFormat = convertDateFormatForLuxon(format);
2815+
const scale = getChartConfiguration(model, chartId).options.scales.x;
2816+
expect(scale.type).toEqual("time");
2817+
expect(scale.ticks?.callback).toBeUndefined();
2818+
expect(scale.time).toEqual({
2819+
displayFormats: { day: convertedFormat }, // luxon format
2820+
parser: convertedFormat,
2821+
tooltipFormat: convertedFormat,
2822+
unit: "day",
2823+
});
2824+
}
2825+
);
28212826

28222827
test("time axis for line/bar chart with formulas w/ date format as labels", () => {
28232828
setCellContent(model, "C2", "=DATE(2022,1,1)");

0 commit comments

Comments
 (0)