FIX: Redo relative-time-picker (#27651)

Fixes various issues with the picker
This commit is contained in:
Jarek Radosz 2024-07-03 14:15:21 +02:00 committed by GitHub
parent 751750c7f8
commit 89c0123b3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 411 additions and 229 deletions

View File

@ -2,104 +2,63 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { isBlank } from "@ember/utils";
import { eq } from "truth-helpers";
import I18n from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
const HOUR = 60;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
function roundDuration(duration) {
let rounded = parseFloat(duration.toFixed(1));
rounded = Math.round(rounded * 2) / 2;
// don't show decimal point for fraction-less numbers
return rounded % 1 === 0 ? rounded.toFixed(0) : rounded;
}
function inputValueFromMinutes(minutes) {
if (!minutes) {
return null;
} else if (minutes > YEAR) {
return roundDuration(minutes / YEAR);
} else if (minutes > MONTH) {
return roundDuration(minutes / MONTH);
} else if (minutes > DAY) {
return roundDuration(minutes / DAY);
} else if (minutes > HOUR) {
return roundDuration(minutes / HOUR);
} else {
return minutes;
}
}
function intervalFromMinutes(minutes) {
if (minutes > YEAR) {
return "years";
} else if (minutes > MONTH) {
return "months";
} else if (minutes > DAY) {
return "days";
} else if (minutes > HOUR) {
return "hours";
} else {
return "mins";
}
}
export default class RelativeTimePicker extends Component {
@tracked _selectedInterval;
@tracked inputValue;
@tracked duration;
@tracked interval;
_roundedDuration(duration) {
const rounded = parseFloat(duration.toFixed(2));
// showing 2.00 instead of just 2 in the input is weird
return rounded % 1 === 0 ? parseInt(rounded, 10) : rounded;
}
get duration() {
if (this.args.durationMinutes !== undefined) {
return this._durationFromMinutes;
} else {
return this._durationFromHours;
}
}
get selectedInterval() {
if (this._selectedInterval) {
return this._selectedInterval;
} else if (this.args.durationMinutes !== undefined) {
return this._intervalFromMinutes;
} else {
return this._intervalFromHours;
}
}
get _durationFromHours() {
if (this.args.durationHours === null) {
return this.args.durationHours;
} else if (this.args.durationHours >= 8760) {
return this._roundedDuration(this.args.durationHours / 365 / 24);
} else if (this.args.durationHours >= 730) {
return this._roundedDuration(this.args.durationHours / 30 / 24);
} else if (this.args.durationHours >= 24) {
return this._roundedDuration(this.args.durationHours / 24);
} else if (this.args.durationHours >= 1) {
return this.args.durationHours;
} else {
return this._roundedDuration(this.args.durationHours * 60);
}
}
get _intervalFromHours() {
if (this.args.durationHours === null) {
return "hours";
} else if (this.args.durationHours >= 8760) {
return "years";
} else if (this.args.durationHours >= 730) {
return "months";
} else if (this.args.durationHours >= 24) {
return "days";
} else if (this.args.durationHours < 1) {
return "mins";
} else {
return "hours";
}
}
get _durationFromMinutes() {
if (this.args.durationMinutes >= 525600) {
return this._roundedDuration(this.args.durationMinutes / 365 / 60 / 24);
} else if (this.args.durationMinutes >= 43800) {
return this._roundedDuration(this.args.durationMinutes / 30 / 60 / 24);
} else if (this.args.durationMinutes >= 1440) {
return this._roundedDuration(this.args.durationMinutes / 60 / 24);
} else if (this.args.durationMinutes >= 60) {
return this._roundedDuration(this.args.durationMinutes / 60);
} else {
return this.args.durationMinutes;
}
}
get _intervalFromMinutes() {
if (this.args.durationMinutes >= 525600) {
return "years";
} else if (this.args.durationMinutes >= 43800) {
return "months";
} else if (this.args.durationMinutes >= 1440) {
return "days";
} else if (this.args.durationMinutes >= 60) {
return "hours";
} else {
return "mins";
}
}
get durationMin() {
return this.selectedInterval === "mins" ? 1 : 0.1;
}
get durationStep() {
return this.selectedInterval === "mins" ? 1 : 0.05;
constructor() {
super(...arguments);
this.initValues();
}
get intervals() {
@ -129,58 +88,114 @@ export default class RelativeTimePicker extends Component {
].filter((interval) => !this.args.hiddenIntervals?.includes(interval.id));
}
calculateMinutes(duration, interval) {
if (isBlank(duration) || isNaN(duration)) {
minutesFromInputValueAndInterval(duration, interval) {
if (isNaN(duration)) {
return null;
}
duration = parseFloat(duration);
switch (interval) {
case "mins":
// we round up here in case the user manually inputted a step < 1
return Math.ceil(duration);
case "hours":
return duration * 60;
return duration * HOUR;
case "days":
return duration * 60 * 24;
return duration * DAY;
case "months":
return duration * 60 * 24 * 30; // less accurate because of varying days in months
return duration * MONTH; // less accurate because of varying days in months
case "years":
return duration * 60 * 24 * 365; // least accurate because of varying days in months/years
return duration * YEAR; // least accurate because of varying days in months/years
}
}
@action
onChangeInterval(interval) {
this._selectedInterval = interval;
const minutes = this.calculateMinutes(this.duration, interval);
this.args.onChange?.(minutes);
initValues() {
let minutes = this.args.durationMinutes;
if (this.args.durationHours) {
minutes ??= this.args.durationHours * HOUR;
}
this.inputValue = inputValueFromMinutes(minutes);
if (this.args.durationMinutes !== undefined) {
this.interval = intervalFromMinutes(this.args.durationMinutes);
} else if (this.args.durationHours === null) {
this.interval = "hours";
} else if (this.args.durationHours !== undefined) {
this.interval = intervalFromMinutes(this.args.durationHours * HOUR);
} else {
this.interval = "mins";
}
this.duration = this.minutesFromInputValueAndInterval(
this.inputValue,
this.interval
);
}
@action
onChangeDuration(event) {
const minutes = this.calculateMinutes(
event.target.value,
this.selectedInterval
if (isBlank(event.target.value)) {
this.duration = null;
this.inputValue = null;
} else {
let newDuration = this.minutesFromInputValueAndInterval(
parseFloat(event.target.value),
this.interval
);
// if on the edge of an interval - go to the next value
// (e.g. 24 hours -> 1.5 days, instead of 24 hours -> 1 day)
if (
newDuration > this.duration &&
(this.duration === YEAR ||
this.duration === MONTH ||
this.duration === DAY ||
this.duration === HOUR)
) {
newDuration = this.minutesFromInputValueAndInterval(
parseFloat(event.target.value) * 1.5,
this.interval
);
}
this.duration = newDuration;
this.interval = intervalFromMinutes(this.duration);
this.inputValue = inputValueFromMinutes(this.duration);
}
this.args.onChange?.(this.duration);
}
@action
onChangeInterval(interval) {
this.interval = interval;
const newDuration = this.minutesFromInputValueAndInterval(
this.inputValue,
this.interval
);
this.args.onChange?.(minutes);
if (newDuration !== this.duration) {
this.duration = newDuration;
this.args.onChange?.(this.duration);
}
}
<template>
<div class="relative-time-picker">
<div class="relative-time-picker" ...attributes>
<input
{{didUpdate this.initValues @durationMinutes @durationHours}}
{{on "change" this.onChangeDuration}}
type="number"
min={{this.durationMin}}
step={{this.durationStep}}
value={{this.duration}}
min={{if (eq this.interval "mins") 1 0.5}}
step={{if (eq this.interval "mins") 1 0.5}}
value={{this.inputValue}}
id={{@id}}
class="relative-time-duration"
/>
<ComboBox
@content={{this.intervals}}
@value={{this.selectedInterval}}
@value={{this.interval}}
@onChange={{this.onChangeInterval}}
class="relative-time-intervals"
/>

View File

@ -40,9 +40,9 @@
{{i18n "relative_time_picker.relative"}}
</label>
<RelativeTimePicker
@id="bookmark-relative-time-picker"
@durationMinutes={{this.selectedDurationMins}}
@onChange={{action "relativeTimeChanged"}}
@onChange={{this.relativeTimeChanged}}
id="bookmark-relative-time-picker"
/>
</div>
{{/if}}

View File

@ -207,16 +207,14 @@ export default Component.extend({
@action
relativeTimeChanged(relativeTimeMins) {
let dateTime = now(this.userTimezone).add(relativeTimeMins, "minutes");
const dateTime = now(this.userTimezone).add(relativeTimeMins, "minutes");
this.setProperties({
selectedDurationMins: relativeTimeMins,
selectedDatetime: dateTime,
});
if (this.onTimeSelected) {
this.onTimeSelected(TIME_SHORTCUT_TYPES.RELATIVE, dateTime);
}
this.onTimeSelected?.(TIME_SHORTCUT_TYPES.RELATIVE, dateTime);
},
@action

View File

@ -0,0 +1,279 @@
import { tracked } from "@glimmer/tracking";
import { fillIn, render, settled, typeIn } from "@ember/test-helpers";
import { module, test } from "qunit";
import RelativeTimePicker from "discourse/components/relative-time-picker";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import selectKit from "discourse/tests/helpers/select-kit-helper";
module("Integration | Component | relative-time-picker", function (hooks) {
setupRenderingTest(hooks);
test("calls the onChange arg", async function (assert) {
let updatedValue;
const update = (value) => (updatedValue = value);
await render(<template>
<RelativeTimePicker @onChange={{update}} />
</template>);
// empty and "minutes" by default
assert.dom(".relative-time-duration").hasValue("");
assert.strictEqual(
selectKit().header().value(),
"mins",
"dropdown has 'minutes' preselected"
);
// type <60 minutes
await typeIn(".relative-time-duration", "50");
assert.dom(".relative-time-duration").hasValue("50");
assert.strictEqual(updatedValue, 50, "onChange called with 50");
// select "hours"
await selectKit().expand();
await selectKit().selectRowByValue("hours");
assert.dom(".relative-time-duration").hasValue("50");
assert.strictEqual(updatedValue, 50 * 60, "onChange called with 50 * 60");
// clear the duration
await fillIn(".relative-time-duration", "");
assert.dom(".relative-time-duration").hasValue("");
assert.strictEqual(updatedValue, null, "onChange called with null");
assert.strictEqual(
selectKit().header().value(),
"hours",
"dropdown has 'hours' selected"
);
// type a new value
await typeIn(".relative-time-duration", "22");
assert.strictEqual(
selectKit().header().value(),
"hours",
"dropdown has still 'hours' selected"
);
assert.dom(".relative-time-duration").hasValue("22");
assert.strictEqual(updatedValue, 22 * 60, "onChange called with 22 * 60");
// select "minutes"
await selectKit().expand();
await selectKit().selectRowByValue("mins");
assert.dom(".relative-time-duration").hasValue("22");
assert.strictEqual(updatedValue, 22, "onChange called with 22");
// type >60 minutes
await fillIn(".relative-time-duration", "");
await typeIn(".relative-time-duration", "800");
assert.dom(".relative-time-duration").hasValue("13.5");
assert.strictEqual(updatedValue, 800, "onChange called with 800");
assert.strictEqual(
selectKit().header().value(),
"hours",
"automatically changes the dropdown to 'hours'"
);
});
test("onChange callback works w/ a start value", async function (assert) {
const testState = new (class {
@tracked minutes = 120;
})();
const update = (value) => (testState.minutes = value);
await render(<template>
<RelativeTimePicker
@onChange={{update}}
@durationMinutes={{testState.minutes}}
/>
</template>);
// uses the value and selects the right interval
assert.dom(".relative-time-duration").hasValue("2");
assert.strictEqual(
selectKit().header().value(),
"hours",
"dropdown has 'hours' preselected"
);
// clear the duration
await fillIn(".relative-time-duration", "");
assert.dom(".relative-time-duration").hasValue("");
assert.strictEqual(testState.minutes, null, "onChange called with null");
// semi-acceptable behavior: because `initValues()` is called, it changes the interval:
assert.strictEqual(
selectKit().header().value(),
"mins",
"dropdown has 'minutes' selected"
);
// type <60 minutes
await typeIn(".relative-time-duration", "18");
assert.dom(".relative-time-duration").hasValue("18");
assert.strictEqual(testState.minutes, 18, "onChange called with 18");
// select "days"
await selectKit().expand();
await selectKit().selectRowByValue("days");
assert.dom(".relative-time-duration").hasValue("18");
assert.strictEqual(
testState.minutes,
18 * 60 * 24,
"onChange called with 18 * 60 * 24"
);
// type a new value
await fillIn(".relative-time-duration", "2");
assert.strictEqual(
selectKit().header().value(),
"days",
"dropdown has still 'days' selected"
);
assert.dom(".relative-time-duration").hasValue("2");
assert.strictEqual(
testState.minutes,
2 * 60 * 24,
"onChange called with 2 * 60 * 24"
);
// select "minutes"
await selectKit().expand();
await selectKit().selectRowByValue("mins");
assert.dom(".relative-time-duration").hasValue("2");
assert.strictEqual(testState.minutes, 2, "onChange called with 2");
// type >60 minutes
await fillIn(".relative-time-duration", "90");
assert.dom(".relative-time-duration").hasValue("1.5");
assert.strictEqual(testState.minutes, 90, "onChange called with 90");
assert.strictEqual(
selectKit().header().value(),
"hours",
"automatically changes the dropdown to 'hours'"
);
});
test("updates the input when args change", async function (assert) {
const testState = new (class {
@tracked value;
})();
testState.value = 10;
await render(<template>
<RelativeTimePicker @durationMinutes={{testState.value}} />
</template>);
assert.strictEqual(selectKit().header().value(), "mins");
assert.dom(".relative-time-duration").hasValue("10");
testState.value = 20;
await settled();
assert.dom(".relative-time-duration").hasValue("20");
});
test("prefills and preselects minutes", async function (assert) {
await render(<template>
<RelativeTimePicker @durationMinutes="5" />
</template>);
assert.strictEqual(selectKit().header().value(), "mins");
assert.dom(".relative-time-duration").hasValue("5");
});
test("prefills and preselects null minutes", async function (assert) {
await render(<template>
<RelativeTimePicker @durationMinutes={{null}} />
</template>);
assert.strictEqual(selectKit().header().value(), "mins");
assert.dom(".relative-time-duration").hasValue("");
});
test("prefills and preselects hours based on converted minutes", async function (assert) {
await render(<template>
<RelativeTimePicker @durationMinutes="90" />
</template>);
assert.strictEqual(selectKit().header().value(), "hours");
assert.dom(".relative-time-duration").hasValue("1.5");
});
test("prefills and preselects days based on converted minutes", async function (assert) {
await render(<template>
<RelativeTimePicker @durationMinutes="2880" />
</template>);
assert.strictEqual(selectKit().header().value(), "days");
assert.dom(".relative-time-duration").hasValue("2");
});
test("prefills and preselects months based on converted minutes", async function (assert) {
await render(<template>
<RelativeTimePicker @durationMinutes="151200" />
</template>);
assert.strictEqual(selectKit().header().value(), "months");
assert.dom(".relative-time-duration").hasValue("3.5");
});
test("prefills and preselects years based on converted minutes", async function (assert) {
await render(<template>
<RelativeTimePicker @durationMinutes="525700" />
</template>);
assert.strictEqual(selectKit().header().value(), "years");
assert.dom(".relative-time-duration").hasValue("1");
});
test("prefills and preselects hours", async function (assert) {
await render(<template>
<RelativeTimePicker @durationHours="5" />
</template>);
assert.strictEqual(selectKit().header().value(), "hours");
assert.dom(".relative-time-duration").hasValue("5");
});
test("prefills and preselects null hours", async function (assert) {
await render(<template>
<RelativeTimePicker @durationHours={{null}} />
</template>);
assert.strictEqual(selectKit().header().value(), "hours");
assert.dom(".relative-time-duration").hasValue("");
});
test("prefills and preselects minutes based on converted hours", async function (assert) {
await render(<template>
<RelativeTimePicker @durationHours="0.5" />
</template>);
assert.strictEqual(selectKit().header().value(), "mins");
assert.dom(".relative-time-duration").hasValue("30");
});
test("prefills and preselects days based on converted hours", async function (assert) {
await render(<template>
<RelativeTimePicker @durationHours="48" />
</template>);
assert.strictEqual(selectKit().header().value(), "days");
assert.dom(".relative-time-duration").hasValue("2");
});
test("prefills and preselects months based on converted hours", async function (assert) {
await render(<template>
<RelativeTimePicker @durationHours="2160" />
</template>);
assert.strictEqual(selectKit().header().value(), "months");
assert.dom(".relative-time-duration").hasValue("3");
});
test("prefills and preselects years based on converted hours", async function (assert) {
await render(<template>
<RelativeTimePicker @durationHours="17520" />
</template>);
assert.strictEqual(selectKit().header().value(), "years");
assert.dom(".relative-time-duration").hasValue("2");
});
});

View File

@ -1,110 +0,0 @@
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
module("Integration | Component | relative-time-picker", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.set("subject", selectKit());
});
test("prefills and preselects minutes", async function (assert) {
await render(hbs`<RelativeTimePicker @durationMinutes="5" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "mins");
assert.strictEqual(prefilledDuration, "5");
});
test("prefills and preselects null minutes", async function (assert) {
await render(hbs`<RelativeTimePicker @durationMinutes={{null}} />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "mins");
assert.strictEqual(prefilledDuration, "");
});
test("prefills and preselects hours based on translated minutes", async function (assert) {
await render(hbs`<RelativeTimePicker @durationMinutes="90" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "hours");
assert.strictEqual(prefilledDuration, "1.5");
});
test("prefills and preselects days based on translated minutes", async function (assert) {
await render(hbs`<RelativeTimePicker @durationMinutes="2880" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "days");
assert.strictEqual(prefilledDuration, "2");
});
test("prefills and preselects months based on translated minutes", async function (assert) {
await render(hbs`<RelativeTimePicker @durationMinutes="129600" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "months");
assert.strictEqual(prefilledDuration, "3");
});
test("prefills and preselects years based on translated minutes", async function (assert) {
await render(hbs`<RelativeTimePicker @durationMinutes="525600" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "years");
assert.strictEqual(prefilledDuration, "1");
});
test("prefills and preselects hours", async function (assert) {
await render(hbs`<RelativeTimePicker @durationHours="5" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "hours");
assert.strictEqual(prefilledDuration, "5");
});
test("prefills and preselects null hours", async function (assert) {
await render(hbs`<RelativeTimePicker @durationHours={{null}} />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "hours");
assert.strictEqual(prefilledDuration, "");
});
test("prefills and preselects minutes based on translated hours", async function (assert) {
await render(hbs`<RelativeTimePicker @durationHours="0.5" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "mins");
assert.strictEqual(prefilledDuration, "30");
});
test("prefills and preselects days based on translated hours", async function (assert) {
await render(hbs`<RelativeTimePicker @durationHours="48" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "days");
assert.strictEqual(prefilledDuration, "2");
});
test("prefills and preselects months based on translated hours", async function (assert) {
await render(hbs`<RelativeTimePicker @durationHours="2160" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "months");
assert.strictEqual(prefilledDuration, "3");
});
test("prefills and preselects years based on translated hours", async function (assert) {
await render(hbs`<RelativeTimePicker @durationHours="17520" />`);
const prefilledDuration = query(".relative-time-duration").value;
assert.strictEqual(this.subject.header().value(), "years");
assert.strictEqual(prefilledDuration, "2");
});
});