diff --git a/frontend/src/metabase-lib/lib/Dimension.js b/frontend/src/metabase-lib/lib/Dimension.js index 7a672272b96c645e1c93b5941731aa0e59d6f9af..2fadb61be5a10a115cc1df39f707249ba26d880f 100644 --- a/frontend/src/metabase-lib/lib/Dimension.js +++ b/frontend/src/metabase-lib/lib/Dimension.js @@ -513,7 +513,7 @@ export class DatetimeFieldDimension extends FieldDimension { } subTriggerDisplayName(): string { - return "by " + formatBucketing(this._args[0]).toLowerCase(); + return t`by ${formatBucketing(this._args[0]).toLowerCase()}`; } render() { diff --git a/frontend/src/metabase/components/Calendar.jsx b/frontend/src/metabase/components/Calendar.jsx index 28ef9c248b058fa25ebb5ab146161f4069e4d7e5..f456c2cf14bdb0efaa7e5adb80f15291d2968d02 100644 --- a/frontend/src/metabase/components/Calendar.jsx +++ b/frontend/src/metabase/components/Calendar.jsx @@ -11,7 +11,6 @@ import Icon from "metabase/components/Icon"; export default class Calendar extends Component { constructor(props) { super(props); - this.state = { current: moment(props.initial || undefined), }; diff --git a/frontend/src/metabase/lib/i18n-debug.js b/frontend/src/metabase/lib/i18n-debug.js index 82e2b33df1d30615fe17e233b7734070dfa81040..429e9e4d64b844b95f012bf670ce1bf2fc193c13 100644 --- a/frontend/src/metabase/lib/i18n-debug.js +++ b/frontend/src/metabase/lib/i18n-debug.js @@ -25,8 +25,8 @@ const SPECIAL_STRINGS = new Set([ "Max", ]); -const obfuscateString = string => { - if (SPECIAL_STRINGS.has(string)) { +const obfuscateString = (original, string) => { + if (SPECIAL_STRINGS.has(original)) { return string.toUpperCase(); } else { // divide by 2 because Unicode `FULL BLOCK` is quite wide @@ -40,10 +40,10 @@ export function enableTranslatedStringReplacement() { const _jt = c3po.jt; const _ngettext = c3po.ngettext; c3po.t = (...args) => { - return obfuscateString(_t(...args)); + return obfuscateString(args[0][0], _t(...args)); }; c3po.ngettext = (...args) => { - return obfuscateString(_ngettext(...args)); + return obfuscateString(args[0][0], _ngettext(...args)); }; // eslint-disable-next-line react/display-name c3po.jt = (...args) => { diff --git a/frontend/src/metabase/lib/i18n.js b/frontend/src/metabase/lib/i18n.js index 3824c5308a4dcf49d0e97c49af80383075e64a5d..42d2130b8bfc21eb9a3d025346172f9c59bc5b1c 100644 --- a/frontend/src/metabase/lib/i18n.js +++ b/frontend/src/metabase/lib/i18n.js @@ -1,4 +1,5 @@ import { addLocale, useLocale } from "c-3po"; +import moment from "moment"; // NOTE: loadLocalization not currently used, and we need to be sure to set the // initial localization before loading any files, so don't load metabase/services @@ -20,6 +21,8 @@ export function setLocalization(translationsObject) { // add and set locale with C-3PO addLocale(locale, translationsObject); useLocale(locale); + + moment.locale(locale); } // we delete msgid property since it's redundant, but have to add it back in to diff --git a/frontend/src/metabase/lib/query_time.js b/frontend/src/metabase/lib/query_time.js index b672004d2593e63af94a23392da844a5de9e9208..a96c514d8bb336cd50db4cee36e7396538178712 100644 --- a/frontend/src/metabase/lib/query_time.js +++ b/frontend/src/metabase/lib/query_time.js @@ -3,6 +3,7 @@ import inflection from "inflection"; import { formatDateTimeWithUnit } from "metabase/lib/formatting"; import { parseTimestamp } from "metabase/lib/time"; +import { t, ngettext, msgid } from "c-3po"; export const DATETIME_UNITS = [ // "default", @@ -108,34 +109,38 @@ export function generateTimeIntervalDescription(n, unit) { switch (n) { case "current": case 0: - return ["Today"]; + return [t`Today`]; case "next": case 1: - return ["Tomorrow"]; + return [t`Tomorrow`]; case "last": case -1: - return ["Yesterday"]; + return [t`Yesterday`]; } } if (!unit && n === 0) { - return "Today"; + return t`Today`; } // ['relative-datetime', 'current'] is a legal MBQL form but has no unit - unit = inflection.capitalize(unit); - if (typeof n === "string") { - if (n === "current") { - n = "this"; - } - return [inflection.capitalize(n) + " " + unit]; + switch (n) { + case "current": + case 0: + return [t`This ${formatBucketing(unit)}`]; + case "next": + case 1: + return [t`Next ${formatBucketing(unit)}`]; + case "last": + case -1: + return [t`Previous ${formatBucketing(unit)}`]; + } + + if (n < 0) { + return [t`Previous ${-n} ${formatBucketing(unit, -n)}`]; + } else if (n > 0) { + return [t`Next ${n} ${formatBucketing(unit, n)}`]; } else { - if (n < 0) { - return ["Past " + -n + " " + inflection.inflect(unit, -n)]; - } else if (n > 0) { - return ["Next " + n + " " + inflection.inflect(unit, n)]; - } else { - return ["This " + unit]; - } + return [t`This ${formatBucketing(unit)}`]; } } @@ -163,23 +168,54 @@ export function generateTimeValueDescription(value, bucketing) { } else { // FIXME: what to do if the bucketing and unit don't match? if (n === 0) { - return "Now"; + return t`Now`; } else { - return ( - Math.abs(n) + - " " + - inflection.inflect(unit, Math.abs(n)) + - (n < 0 ? " ago" : " from now") - ); + return n < 0 + ? t`${-n} ${formatBucketing(unit, -n).toLowerCase()} ago` + : t`${n} ${formatBucketing(unit, n).toLowerCase()} from now`; } } } else { console.warn("Unknown datetime format", value); - return "[Unknown]"; + return `[${t`Unknown`}]`; } } -export function formatBucketing(bucketing = "") { +export function formatBucketing(bucketing = "", n = 1) { + switch (bucketing) { + case "default": + return ngettext(msgid`Default period`, `Default periods`, n); + case "minute": + return ngettext(msgid`Minute`, `Minutes`, n); + case "hour": + return ngettext(msgid`Hour`, `Hours`, n); + case "day": + return ngettext(msgid`Day`, `Days`, n); + case "week": + return ngettext(msgid`Week`, `Weeks`, n); + case "month": + return ngettext(msgid`Month`, `Months`, n); + case "quarter": + return ngettext(msgid`Quarter`, `Quarters`, n); + case "year": + return ngettext(msgid`Year`, `Years`, n); + case "minute-of-hour": + return ngettext(msgid`Minute of hour`, `Minutes of hour`, n); + case "hour-of-day": + return ngettext(msgid`Hour of day`, `Hours of day`, n); + case "day-of-week": + return ngettext(msgid`Day of week`, `Days of week`, n); + case "day-of-month": + return ngettext(msgid`Day of month`, `Days of month`, n); + case "day-of-year": + return ngettext(msgid`Day of year`, `Days of year`, n); + case "week-of-year": + return ngettext(msgid`Week of year`, `Weeks of year`, n); + case "month-of-year": + return ngettext(msgid`Month of year`, `Months of year`, n); + case "quarter-of-year": + return ngettext(msgid`Quarter of year`, `Quarters of year`, n); + } let words = bucketing.split("-"); words[0] = inflection.capitalize(words[0]); return words.join(" "); @@ -255,6 +291,7 @@ export function parseFieldTargetId(field) { function max() { return moment(new Date(864000000000000)); } + function min() { return moment(new Date(-864000000000000)); } diff --git a/frontend/src/metabase/parameters/components/widgets/CategoryWidget.jsx b/frontend/src/metabase/parameters/components/widgets/CategoryWidget.jsx index 2e09d803b9258afb544ac1cbe0ac3d39d868a247..9378f8e573faecaa1d87864f3d80a7e93031e236 100644 --- a/frontend/src/metabase/parameters/components/widgets/CategoryWidget.jsx +++ b/frontend/src/metabase/parameters/components/widgets/CategoryWidget.jsx @@ -3,7 +3,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; -import { t } from "c-3po"; +import { t, ngettext, msgid } from "c-3po"; import { createMultiwordSearchRegex } from "metabase/lib/string"; import { getHumanReadableValue } from "metabase/lib/query/field"; @@ -58,7 +58,8 @@ export default class CategoryWidget extends Component { static format(values, fieldValues) { if (Array.isArray(values) && values.length > 1) { - return `${values.length} selections`; + const n = values.length; + return ngettext(msgid`${n} selection`, `${n} selections`, n); } else { return getHumanReadableValue(values, fieldValues); } diff --git a/frontend/src/metabase/parameters/components/widgets/DateQuarterYearWidget.jsx b/frontend/src/metabase/parameters/components/widgets/DateQuarterYearWidget.jsx index 2ce676ec775b542cadf4652feda08fe499b943e8..bfaddfa47309a9c08963501fa3128eaa8f9a7906 100644 --- a/frontend/src/metabase/parameters/components/widgets/DateQuarterYearWidget.jsx +++ b/frontend/src/metabase/parameters/components/widgets/DateQuarterYearWidget.jsx @@ -5,6 +5,10 @@ import YearPicker from "./YearPicker.jsx"; import moment from "moment"; import _ from "underscore"; import cx from "classnames"; +import { t } from "c-3po"; + +// translator: this is a "moment" format string (https://momentjs.com/docs/#/displaying/format/) It should include "Q" for the quarter number, and raw text can be escaped by brackets. For eample "[Quarter] Q" will be rendered as "Quarter 1" etc +const QUARTER_FORMAT_STRING = t`[Q]Q`; export default class DateQuarterYearWidget extends Component { constructor(props, context) { @@ -84,6 +88,6 @@ const Quarter = ({ quarter, selected, onClick }) => ( > {moment() .quarter(quarter) - .format("[Q]Q")} + .format(QUARTER_FORMAT_STRING)} </li> ); diff --git a/frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx b/frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx index 017550f6413de017daff4b1c1fda0e26476eccd5..e7762602322608b04663ff7c21203f8bac264117 100644 --- a/frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx +++ b/frontend/src/metabase/parameters/components/widgets/DateRelativeWidget.jsx @@ -20,12 +20,12 @@ const SHORTCUTS = [ ]; const RELATIVE_SHORTCUTS = { - Last: [ + [t`Last`]: [ { name: t`Week`, operator: "time-interval", values: ["last", "week"] }, { name: t`Month`, operator: "time-interval", values: ["last", "month"] }, { name: t`Year`, operator: "time-interval", values: ["last", "year"] }, ], - This: [ + [t`This`]: [ { name: t`Week`, operator: "time-interval", values: ["current", "week"] }, { name: t`Month`, operator: "time-interval", values: ["current", "month"] }, { name: t`Year`, operator: "time-interval", values: ["current", "year"] }, diff --git a/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx b/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx index 272138c9743943057f06e06643737a3d05d82338..5a6559cdbab3f45ac0568ab9f6fdf3d4f6d938aa 100644 --- a/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx +++ b/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx @@ -3,7 +3,7 @@ import React, { Component } from "react"; import ReactDOM from "react-dom"; -import { t } from "c-3po"; +import { t, ngettext, msgid } from "c-3po"; import FieldValuesWidget from "metabase/components/FieldValuesWidget"; import Popover from "metabase/components/Popover"; @@ -54,7 +54,8 @@ export default class ParameterFieldWidget extends Component<*, Props, State> { static format(value, field) { value = normalizeValue(value); if (value.length > 1) { - return `${value.length} selections`; + const n = value.length; + return ngettext(msgid`${n} selection`, `${n} selections`, n); } else { return <RemappedValue value={value[0]} column={field} />; } diff --git a/frontend/src/metabase/query_builder/components/Filter.jsx b/frontend/src/metabase/query_builder/components/Filter.jsx index 88043e72578eca5f268573d89a721d28648cdf81..8b02c1cb072560638cf6c8c6f43949285830451e 100644 --- a/frontend/src/metabase/query_builder/components/Filter.jsx +++ b/frontend/src/metabase/query_builder/components/Filter.jsx @@ -11,7 +11,7 @@ import { generateTimeFilterValuesDescriptions } from "metabase/lib/query_time"; import { hasFilterOptions } from "metabase/lib/query/filter"; import { getFilterArgumentFormatOptions } from "metabase/lib/schema_metadata"; -import { t } from "c-3po"; +import { t, ngettext, msgid } from "c-3po"; import type { Filter as FilterT } from "metabase/meta/types/Query"; import type { Value as ValueType } from "metabase/meta/types/Dataset"; @@ -75,7 +75,8 @@ export const OperatorFilter = ({ let formattedValues; // $FlowFixMe: not understanding maxDisplayValues is provided by defaultProps if (operator && operator.multi && values.length > maxDisplayValues) { - formattedValues = [values.length + " selections"]; + const n = values.length; + formattedValues = [ngettext(msgid`${n} selection`, `${n} selections`, n)]; } else if (dimension.field().isDate() && !dimension.field().isTime()) { formattedValues = generateTimeFilterValuesDescriptions(filter); } else { diff --git a/frontend/src/metabase/query_builder/components/filters/DateUnitSelector.jsx b/frontend/src/metabase/query_builder/components/filters/DateUnitSelector.jsx index 6a77ed12ed568454b95c2bc6af51c6da71f7b04a..194139ac33e2ea7042438a6d1bcf06d38f2a81c4 100644 --- a/frontend/src/metabase/query_builder/components/filters/DateUnitSelector.jsx +++ b/frontend/src/metabase/query_builder/components/filters/DateUnitSelector.jsx @@ -1,7 +1,7 @@ import React from "react"; import Select, { Option } from "metabase/components/Select"; -import { pluralize, capitalize } from "humanize-plus"; +import { formatBucketing } from "metabase/lib/query_time"; type DateUnitSelectorProps = { value: RelativeDatetimeUnit, @@ -30,7 +30,7 @@ const DateUnitSelector = ({ > {periods.map(period => ( <Option value={period} key={period}> - {capitalize(pluralize(formatter(intervals) || 1, period))} + {formatBucketing(period, formatter(intervals) || 1)} </Option> ))} </Select> diff --git a/frontend/src/metabase/query_builder/components/filters/pickers/HoursMinutesInput.jsx b/frontend/src/metabase/query_builder/components/filters/pickers/HoursMinutesInput.jsx index 1ddfaf6e459f45f4a546f74ecb7102bbb719e9a1..0d0ff5f4f225f2923d44584adb2656c19cb3949b 100644 --- a/frontend/src/metabase/query_builder/components/filters/pickers/HoursMinutesInput.jsx +++ b/frontend/src/metabase/query_builder/components/filters/pickers/HoursMinutesInput.jsx @@ -4,6 +4,7 @@ import NumericInput from "metabase/components/NumericInput.jsx"; import Icon from "metabase/components/Icon"; import cx from "classnames"; +import moment from "moment"; const HoursMinutesInput = ({ hours, @@ -11,6 +12,7 @@ const HoursMinutesInput = ({ onChangeHours, onChangeMinutes, onClear, + is24HourMode = false, }) => ( <div className="flex align-center"> <NumericInput @@ -18,8 +20,16 @@ const HoursMinutesInput = ({ style={{ height: 36 }} size={2} maxLength={2} - value={hours % 12 === 0 ? "12" : String(hours % 12)} - onChange={value => onChangeHours((hours >= 12 ? 12 : 0) + value)} + value={ + is24HourMode + ? String(hours) + : hours % 12 === 0 ? "12" : String(hours % 12) + } + onChange={ + is24HourMode + ? value => onChangeHours(value) + : value => onChangeHours((hours >= 12 ? 12 : 0) + value) + } /> <span className="px1">:</span> <NumericInput @@ -30,26 +40,28 @@ const HoursMinutesInput = ({ value={(minutes < 10 ? "0" : "") + minutes} onChange={value => onChangeMinutes(value)} /> - <div className="flex align-center pl1"> - <span - className={cx("text-purple-hover mr1", { - "text-purple": hours < 12, - "cursor-pointer": hours >= 12, - })} - onClick={hours >= 12 ? () => onChangeHours(hours - 12) : null} - > - AM - </span> - <span - className={cx("text-purple-hover mr1", { - "text-purple": hours >= 12, - "cursor-pointer": hours < 12, - })} - onClick={hours < 12 ? () => onChangeHours(hours + 12) : null} - > - PM - </span> - </div> + {!is24HourMode && ( + <div className="flex align-center pl1"> + <span + className={cx("text-purple-hover mr1", { + "text-purple": hours < 12, + "cursor-pointer": hours >= 12, + })} + onClick={hours >= 12 ? () => onChangeHours(hours - 12) : null} + > + {moment.localeData().meridiem(0)} + </span> + <span + className={cx("text-purple-hover mr1", { + "text-purple": hours >= 12, + "cursor-pointer": hours < 12, + })} + onClick={hours < 12 ? () => onChangeHours(hours + 12) : null} + > + {moment.localeData().meridiem(12)} + </span> + </div> + )} {onClear && ( <Icon className="text-light cursor-pointer text-medium-hover ml-auto" diff --git a/frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx b/frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx index c703fbea478b70d17e9bbbcb48c443cfd86df29a..f5c77bb0a6a2adb7ac847ae3d9fec420196c89f0 100644 --- a/frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx +++ b/frontend/src/metabase/query_builder/components/filters/pickers/SpecificDatePicker.jsx @@ -144,7 +144,7 @@ export default class SpecificDatePicker extends Component { onClick={() => this.onChange(date, 12, 30)} > <Icon className="mr1" name="clock" /> - Add a time + {t`Add a time`} </div> ) : ( <HoursMinutesInput diff --git a/frontend/test/metabase/lib/query_time.unit.spec.js b/frontend/test/metabase/lib/query_time.unit.spec.js index 1c9a879ddc1d2004835476b926de61401309fe98..020d4328c73427a4dd6e633ed37dddf081c678c9 100644 --- a/frontend/test/metabase/lib/query_time.unit.spec.js +++ b/frontend/test/metabase/lib/query_time.unit.spec.js @@ -98,7 +98,7 @@ describe("query_time", () => { -30, "day", ]), - ).toEqual(["Past 30 Days"]); + ).toEqual(["Previous 30 Days"]); expect( generateTimeFilterValuesDescriptions([ "time-interval", @@ -106,7 +106,7 @@ describe("query_time", () => { 1, "month", ]), - ).toEqual(["Next 1 Month"]); + ).toEqual(["Next Month"]); expect( generateTimeFilterValuesDescriptions([ "time-interval", @@ -130,7 +130,7 @@ describe("query_time", () => { -1, "month", ]), - ).toEqual(["Past 1 Month"]); + ).toEqual(["Previous Month"]); expect( generateTimeFilterValuesDescriptions([ "time-interval", @@ -138,7 +138,7 @@ describe("query_time", () => { -2, "month", ]), - ).toEqual(["Past 2 Months"]); + ).toEqual(["Previous 2 Months"]); }); it("should format 'time-interval' short names correctly", () => { expect( diff --git a/frontend/test/metabase/modes/TimeseriesFilterWidget.unit.spec.jsx b/frontend/test/metabase/modes/TimeseriesFilterWidget.unit.spec.jsx index e500733986b92b8ebe3ee088b3b874280d6661ec..94819a8c79c7c2267e7abe3b2878e8acb6d175b7 100644 --- a/frontend/test/metabase/modes/TimeseriesFilterWidget.unit.spec.jsx +++ b/frontend/test/metabase/modes/TimeseriesFilterWidget.unit.spec.jsx @@ -34,14 +34,14 @@ describe("TimeseriesFilterWidget", () => { const widget = mount(getTimeseriesFilterWidget(questionWithoutFilter)); expect(widget.find(".AdminSelect-content").text()).toBe("All Time"); }); - it("should display 'Past 30 Days' text if that filter is selected", () => { + it("should display 'Previous 30 Days' text if that filter is selected", () => { const questionWithFilter = questionWithoutFilter .query() .addFilter(["time-interval", ["field-id", 1], -30, "day"]) .question(); const widget = mount(getTimeseriesFilterWidget(questionWithFilter)); - expect(widget.find(".AdminSelect-content").text()).toBe("Past 30 Days"); + expect(widget.find(".AdminSelect-content").text()).toBe("Previous 30 Days"); }); it("should display 'Is Empty' text if that filter is selected", () => { const questionWithFilter = questionWithoutFilter diff --git a/frontend/test/metabase/query_builder/components/FieldList.e2e.spec.js b/frontend/test/metabase/query_builder/components/FieldList.e2e.spec.js index 049627d67867d31490f1f0f057f05cbdf32f4c1d..d36cf498f30b06a4512efae5374278301410f080 100644 --- a/frontend/test/metabase/query_builder/components/FieldList.e2e.spec.js +++ b/frontend/test/metabase/query_builder/components/FieldList.e2e.spec.js @@ -121,6 +121,6 @@ describe("FieldList", () => { .last() .text(), // eslint-disable-next-line no-irregular-whitespace - ).toMatch(/Created AtPast 300 Days/); + ).toMatch(/Created AtPrevious 300 Days/); }); }); diff --git a/frontend/test/metabase/query_builder/components/dataref/SegmentPane.e2e.spec.js b/frontend/test/metabase/query_builder/components/dataref/SegmentPane.e2e.spec.js index 755242c705e1e33e56dc5640fdb2753f29a3c67e..ebb6284122c8dd52d5ce89bbe374576392c96e57 100644 --- a/frontend/test/metabase/query_builder/components/dataref/SegmentPane.e2e.spec.js +++ b/frontend/test/metabase/query_builder/components/dataref/SegmentPane.e2e.spec.js @@ -76,7 +76,7 @@ describe("SegmentPane", () => { .find(DataReference) .find(QueryDefinition); // eslint-disable-next-line no-irregular-whitespace - expect(queryDefinition.text()).toMatch(/Created AtPast 300 Days/); + expect(queryDefinition.text()).toMatch(/Created AtPrevious 300 Days/); }); it("lets you apply the filter to your current query", async () => { diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj index 7bdebc4ed0953dd1bb375b935e5fcae39cc7d546..1fa1a02fc366d1c570917e812dbd8d370bdb71cd 100644 --- a/src/metabase/pulse/render.clj +++ b/src/metabase/pulse/render.clj @@ -235,19 +235,19 @@ (case unit :day (date->interval-name parsed-timestamp (t/date-midnight (year) (month) (day)) - (t/days 1) "Today" "Yesterday") + (t/days 1) (tru "Today") (tru "Yesterday")) :week (date->interval-name parsed-timestamp (start-of-this-week) - (t/weeks 1) "This week" "Last week") + (t/weeks 1) (tru "This week") (tru "Last week")) :month (date->interval-name parsed-timestamp (t/date-midnight (year) (month)) - (t/months 1) "This month" "Last month") + (t/months 1) (tru "This month") (tru "Last month")) :quarter (date->interval-name parsed-timestamp (start-of-this-quarter) - (t/months 3) "This quarter" "Last quarter") + (t/months 3) (tru "This quarter") (tru "Last quarter")) :year (date->interval-name (t/date-midnight parsed-timestamp) (t/date-midnight (year)) - (t/years 1) "This year" "Last year") + (t/years 1) (tru "This year") (tru "Last year")) nil))) (defn- format-timestamp-pair