Time Range: Copy-paste Time Range (#80107)

* Working copy-paste functionality with validation

* WIP; uses 't c' shortcut to copy time range

* shortcuts working for explore and dashboards

* cleanup

* Don't update url when pasting in explore

* Error handling, sync pane functionality, add to help modal

* cleanup

* add tests

* fix i18n

* Diferrentiate between explore and dashboard paste events; make on error prop generic

* Fix

* extract getting the copied time range logic into a function

* Remove comments

* Make error handling generic; markup for translations

* Additional translation markup

* markup for aria-label

* Fix test

* Replace fireEvent with userEvent

* fix translations to match the standard pattern

* Refactor keybindingSrv and TimeSrv to remove PasteTimeContext

* Fix test

* Remove unneccessary aria labels; update icons; buttons inline
This commit is contained in:
Haris Rozajac 2024-01-18 14:06:27 -07:00 committed by GitHub
parent e8e8017d51
commit f285eb6717
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 378 additions and 18 deletions

View File

@ -22,6 +22,8 @@ export const Components = {
fromField: 'data-testid Time Range from field',
toField: 'data-testid Time Range to field',
applyTimeRange: 'data-testid TimePicker submit button',
copyTimeRange: 'data-testid TimePicker copy button',
pasteTimeRange: 'data-testid TimePicker paste button',
calendar: {
label: 'data-testid Time Range calendar',
openButton: 'data-testid Open time range calendar',

View File

@ -39,6 +39,7 @@ export interface TimeRangePickerProps {
onMoveBackward: () => void;
onMoveForward: () => void;
onZoom: () => void;
onError?: (error?: string) => void;
history?: TimeRange[];
hideQuickRanges?: boolean;
widthOverride?: number;
@ -58,6 +59,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
onMoveBackward,
onMoveForward,
onZoom,
onError,
timeZone,
fiscalYearStartMonth,
timeSyncButton,
@ -165,6 +167,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
onChangeTimeZone={onChangeTimeZone}
onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth}
hideQuickRanges={hideQuickRanges}
onError={onError}
/>
</section>
</FocusScope>

View File

@ -22,6 +22,7 @@ interface Props {
onChange: (timeRange: TimeRange) => void;
onChangeTimeZone: (timeZone: TimeZone) => void;
onChangeFiscalYearStartMonth?: (month: number) => void;
onError?: (error?: string) => void;
timeZone?: TimeZone;
fiscalYearStartMonth?: number;
quickOptions?: TimeOption[];
@ -122,7 +123,7 @@ export const TimePickerContent = (props: Props) => {
};
const NarrowScreenForm = (props: FormProps) => {
const { value, hideQuickRanges, onChange, timeZone, historyOptions = [], showHistory } = props;
const { value, hideQuickRanges, onChange, timeZone, historyOptions = [], showHistory, onError } = props;
const styles = useStyles2(getNarrowScreenStyles);
const isAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to);
const [collapsedFlag, setCollapsedFlag] = useState(!isAbsolute);
@ -156,7 +157,13 @@ const NarrowScreenForm = (props: FormProps) => {
{!collapsed && (
<div className={styles.body} id="expanded-timerange">
<div className={styles.form}>
<TimeRangeContent value={value} onApply={onChange} timeZone={timeZone} isFullscreen={false} />
<TimeRangeContent
value={value}
onApply={onChange}
timeZone={timeZone}
isFullscreen={false}
onError={onError}
/>
</div>
{showHistory && (
<TimeRangeList
@ -173,7 +180,7 @@ const NarrowScreenForm = (props: FormProps) => {
};
const FullScreenForm = (props: FormProps) => {
const { onChange, value, timeZone, fiscalYearStartMonth, isReversed, historyOptions } = props;
const { onChange, value, timeZone, fiscalYearStartMonth, isReversed, historyOptions, onError } = props;
const styles = useStyles2(getFullScreenStyles, props.hideQuickRanges);
const onChangeTimeOption = (timeOption: TimeOption) => {
return onChange(mapOptionToTimeRange(timeOption, timeZone));
@ -194,6 +201,7 @@ const FullScreenForm = (props: FormProps) => {
onApply={onChange}
isFullscreen={true}
isReversed={isReversed}
onError={onError}
/>
</div>
{props.showHistory && (

View File

@ -1,4 +1,5 @@
import { fireEvent, render, RenderResult } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { dateTimeParse, TimeRange } from '@grafana/data';
@ -10,6 +11,15 @@ type TimeRangeFormRenderResult = RenderResult & {
getCalendarDayByLabelText(label: string): HTMLButtonElement;
};
const mockClipboard = {
writeText: jest.fn(),
readText: jest.fn(),
};
Object.defineProperty(global.navigator, 'clipboard', {
value: mockClipboard,
});
const defaultTimeRange: TimeRange = {
from: dateTimeParse('2021-06-17 00:00:00', { timeZone: 'utc' }),
to: dateTimeParse('2021-06-19 23:59:00', { timeZone: 'utc' }),
@ -19,6 +29,11 @@ const defaultTimeRange: TimeRange = {
},
};
const customRawTimeRange = {
from: '2023-06-17 00:00:00',
to: '2023-06-19 23:59:00',
};
function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc'): TimeRangeFormRenderResult {
const result = render(
<TimeRangeContent isFullscreen={true} value={initial} onApply={() => {}} timeZone={timeZone} />
@ -34,7 +49,7 @@ function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc'): TimeRan
}
describe('TimeRangeForm', () => {
it('should render form correcty', () => {
it('should render form correctly', () => {
const { getByLabelText, getByText, getAllByRole } = setup();
expect(getByText('Apply time range')).toBeInTheDocument();
@ -105,6 +120,26 @@ describe('TimeRangeForm', () => {
expect(to).toHaveClass('react-calendar__tile--rangeEnd');
});
it('should copy time range to clipboard', async () => {
const { getByTestId } = setup();
await userEvent.click(getByTestId('data-testid TimePicker copy button'));
expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(
JSON.stringify({ from: defaultTimeRange.raw.from, to: defaultTimeRange.raw.to })
);
});
it('should paste time range from clipboard', async () => {
const { getByTestId, getByLabelText } = setup();
mockClipboard.readText.mockResolvedValue(JSON.stringify(customRawTimeRange));
await userEvent.click(getByTestId('data-testid TimePicker paste button'));
expect(getByLabelText('From')).toHaveValue(customRawTimeRange.from);
expect(getByLabelText('To')).toHaveValue(customRawTimeRange.to);
});
describe('dates error handling', () => {
it('should show error on invalid dates', () => {
const invalidTimeRange: TimeRange = {

View File

@ -33,6 +33,7 @@ interface Props {
fiscalYearStartMonth?: number;
roundup?: boolean;
isReversed?: boolean;
onError?: (error?: string) => void;
}
interface InputState {
@ -47,7 +48,15 @@ const ERROR_MESSAGES = {
};
export const TimeRangeContent = (props: Props) => {
const { value, isFullscreen = false, timeZone, onApply: onApplyFromProps, isReversed, fiscalYearStartMonth } = props;
const {
value,
isFullscreen = false,
timeZone,
onApply: onApplyFromProps,
isReversed,
fiscalYearStartMonth,
onError,
} = props;
const [fromValue, toValue] = valueToState(value.raw.from, value.raw.to, timeZone);
const style = useStyles2(getStyles);
@ -99,6 +108,29 @@ export const TimeRangeContent = (props: Props) => {
}
};
const onCopy = () => {
const raw: RawTimeRange = { from: from.value, to: to.value };
navigator.clipboard.writeText(JSON.stringify(raw));
};
const onPaste = async () => {
const raw = await navigator.clipboard.readText();
let range;
try {
range = JSON.parse(raw);
} catch (error) {
if (onError) {
onError(raw);
}
return;
}
const [fromValue, toValue] = valueToState(range.from, range.to, timeZone);
setFrom(fromValue);
setTo(toValue);
};
const fiscalYear = rangeUtil.convertRawToRange({ from: 'now/fy', to: 'now/fy' }, timeZone, fiscalYearStartMonth);
const fiscalYearMessage = t('time-picker.range-content.fiscal-year', 'Fiscal year');
@ -159,9 +191,27 @@ export const TimeRangeContent = (props: Props) => {
</Field>
{fyTooltip}
</div>
<Button data-testid={selectors.components.TimePicker.applyTimeRange} type="button" onClick={onApply}>
<Trans i18nKey="time-picker.range-content.apply-button">Apply time range</Trans>
</Button>
<div className={style.buttonsContainer}>
<Button
data-testid={selectors.components.TimePicker.copyTimeRange}
icon="copy"
variant="secondary"
tooltip={t('time-picker.copy-paste.tooltip-copy', 'Copy time range to clipboard')}
type="button"
onClick={onCopy}
/>
<Button
data-testid={selectors.components.TimePicker.pasteTimeRange}
icon="clipboard-alt"
variant="secondary"
tooltip={t('time-picker.copy-paste.tooltip-paste', 'Paste time range')}
type="button"
onClick={onPaste}
/>
<Button data-testid={selectors.components.TimePicker.applyTimeRange} type="button" onClick={onApply}>
<Trans i18nKey="time-picker.range-content.apply-button">Apply time range</Trans>
</Button>
</div>
<TimePickerCalendar
isFullscreen={isFullscreen}
@ -220,6 +270,11 @@ function getStyles(theme: GrafanaTheme2) {
fieldContainer: css({
display: 'flex',
}),
buttonsContainer: css({
display: 'flex',
gap: theme.spacing(0.5),
marginTop: theme.spacing(1),
}),
tooltip: css({
paddingLeft: theme.spacing(1),
paddingTop: theme.spacing(3),

View File

@ -1,8 +1,10 @@
import { uniqBy } from 'lodash';
import React from 'react';
import { TimeRange, isDateTime, rangeUtil } from '@grafana/data';
import { AppEvents, TimeRange, isDateTime, rangeUtil } from '@grafana/data';
import { TimeRangePickerProps, TimeRangePicker } from '@grafana/ui';
import { t } from '@grafana/ui/src/utils/i18n';
import appEvents from 'app/core/app_events';
import { LocalStorageValueProvider } from '../LocalStorageValueProvider';
@ -34,6 +36,12 @@ export const TimePickerWithHistory = (props: Props) => {
onAppendToHistory(value, values, onSaveToStore);
props.onChange(value);
}}
onError={(error?: string) =>
appEvents.emit(AppEvents.alertError, [
t('time-picker.copy-paste.default-error-title', 'Invalid time range'),
t('time-picker.copy-paste.default-error-message', `{{error}} is not a valid time range`, { error }),
])
}
/>
);
}}

View File

@ -121,6 +121,14 @@ const getShortcuts = (modKey: string) => {
'Make time range absolute/permanent'
),
},
{
keys: ['t', 'c'],
description: t('help-modal.shortcuts-description.copy-time-range', 'Copy time range'),
},
{
keys: ['t', 'v'],
description: t('help-modal.shortcuts-description.paste-time-range', 'Paste time range'),
},
],
},
];

View File

@ -18,6 +18,8 @@ import {
ShowModalReactEvent,
ZoomOutEvent,
AbsoluteTimeEvent,
CopyTimeEvent,
PasteTimeEvent,
} from '../../types/events';
import { AppChromeService } from '../components/AppChrome/AppChromeService';
import { HelpModal } from '../components/help/HelpModal';
@ -203,6 +205,14 @@ export class KeybindingSrv {
this.bind('t right', () => {
appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right, updateUrl }));
});
this.bind('t c', () => {
appEvents.publish(new CopyTimeEvent());
});
this.bind('t v', () => {
appEvents.publish(new PasteTimeEvent({ updateUrl }));
});
}
setupDashboardBindings(dashboard: DashboardModel) {

View File

@ -1,4 +1,6 @@
import { TimeRange, toUtc, AbsoluteTimeRange } from '@grafana/data';
import { TimeRange, toUtc, AbsoluteTimeRange, RawTimeRange } from '@grafana/data';
type CopiedTimeRangeResult = { range: RawTimeRange; isError: false } | { range: string; isError: true };
export const getShiftedTimeRange = (direction: number, origRange: TimeRange): AbsoluteTimeRange => {
const range = {
@ -38,3 +40,20 @@ export const getZoomedTimeRange = (range: TimeRange, factor: number): AbsoluteTi
return { from, to };
};
export async function getCopiedTimeRange(): Promise<CopiedTimeRangeResult> {
const raw = await navigator.clipboard.readText();
let range;
try {
range = JSON.parse(raw);
if (!range.from || !range.to) {
return { range: raw, isError: true };
}
return { range, isError: false };
} catch (e) {
return { range: raw, isError: true };
}
}

View File

@ -10,16 +10,25 @@ import {
TimeRange,
toUtc,
IntervalValues,
AppEvents,
} from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes';
import { t } from '@grafana/ui/src/utils/i18n';
import appEvents from 'app/core/app_events';
import { config } from 'app/core/config';
import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv';
import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
import { getTimeRange } from 'app/features/dashboard/utils/timeRange';
import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../types/events';
import {
AbsoluteTimeEvent,
CopyTimeEvent,
PasteTimeEvent,
ShiftTimeEvent,
ShiftTimeEventDirection,
ZoomOutEvent,
} from '../../../types/events';
import { TimeModel } from '../state/TimeModel';
import { getRefreshFromUrl } from '../utils/getRefreshFromUrl';
@ -51,6 +60,14 @@ export class TimeSrv {
this.makeAbsoluteTime(e.payload.updateUrl);
});
appEvents.subscribe(CopyTimeEvent, () => {
this.copyTimeRangeToClipboard();
});
appEvents.subscribe(PasteTimeEvent, (e) => {
this.pasteTimeRangeFromClipboard(e.payload.updateUrl);
});
document.addEventListener('visibilitychange', () => {
if (this.autoRefreshBlocked && document.visibilityState === 'visible') {
this.autoRefreshBlocked = false;
@ -357,6 +374,30 @@ export class TimeSrv {
this.setTime({ from, to }, updateUrl);
}
copyTimeRangeToClipboard() {
const { raw } = this.timeRange();
navigator.clipboard.writeText(JSON.stringify({ from: raw.from, to: raw.to }));
appEvents.emit(AppEvents.alertSuccess, [
t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'),
]);
}
async pasteTimeRangeFromClipboard(updateUrl = true) {
const { range, isError } = await getCopiedTimeRange();
if (isError === true) {
appEvents.emit(AppEvents.alertError, [
t('time-picker.copy-paste.default-error-title', 'Invalid time range'),
t('time-picker.copy-paste.default-error-message', '{{error}} is not a valid time range', { error: range }),
]);
return;
}
const { from, to } = range;
this.setTime({ from, to }, updateUrl);
}
// isRefreshOutsideThreshold function calculates the difference between last refresh and now
// if the difference is outside 5% of the current set time range then the function will return true
// if the difference is within 5% of the current set time range then the function will return false

View File

@ -1,9 +1,16 @@
import { render } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { dateTime, EventBusSrv } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from 'app/types/events';
import {
AbsoluteTimeEvent,
CopyTimeEvent,
PasteTimeEvent,
ShiftTimeEvent,
ShiftTimeEventDirection,
ZoomOutEvent,
} from 'app/types/events';
import { TestProvider } from '../../../../test/helpers/TestProvider';
import { configureStore } from '../../../store/configureStore';
@ -22,6 +29,15 @@ jest.mock('@grafana/runtime', () => {
};
});
const mockClipboard = {
writeText: jest.fn(),
readText: jest.fn(),
};
Object.defineProperty(global.navigator, 'clipboard', {
value: mockClipboard,
});
const NOW = new Date('2020-10-10T00:00:00.000Z');
function daysFromNow(daysDiff: number) {
return new Date(NOW.getTime() + daysDiff * 86400000);
@ -111,4 +127,31 @@ describe('useKeyboardShortcuts', () => {
expect(panes[1]!.absoluteRange.from).toBe(daysFromNow(-3).getTime());
expect(panes[1]!.absoluteRange.to).toBe(daysFromNow(1).getTime());
});
it('copies the time range from the left pane', () => {
const store = setup();
getAppEvents().publish(new CopyTimeEvent());
const fromValue = store.getState().explore.panes.left!.range.raw.from;
const toValue = store.getState().explore.panes.left!.range.raw.to;
expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(JSON.stringify({ from: fromValue, to: toValue }));
});
it('pastes the time range to left pane', async () => {
const store = setup();
const fromValue = 'now-3d';
const toValue = 'now';
mockClipboard.readText.mockResolvedValue(JSON.stringify({ from: fromValue, to: toValue }));
getAppEvents().publish(new PasteTimeEvent({ updateUrl: false }));
await waitFor(() => {
const raw = store.getState().explore.panes.left!.range.raw;
expect(raw.from).toBe(fromValue);
expect(raw.to).toBe(toValue);
});
});
});

View File

@ -4,9 +4,15 @@ import { Unsubscribable } from 'rxjs';
import { getAppEvents } from '@grafana/runtime';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useDispatch } from 'app/types';
import { AbsoluteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events';
import { AbsoluteTimeEvent, CopyTimeEvent, PasteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events';
import { makeAbsoluteTime, shiftTime, zoomOut } from '../state/time';
import {
copyTimeRangeToClipboard,
makeAbsoluteTime,
pasteTimeRangeFromClipboard,
shiftTime,
zoomOut,
} from '../state/time';
export function useKeyboardShortcuts() {
const { keybindings } = useGrafana();
@ -35,6 +41,18 @@ export function useKeyboardShortcuts() {
})
);
tearDown.push(
getAppEvents().subscribe(CopyTimeEvent, () => {
dispatch(copyTimeRangeToClipboard());
})
);
tearDown.push(
getAppEvents().subscribe(PasteTimeEvent, () => {
dispatch(pasteTimeRangeFromClipboard());
})
);
return () => {
tearDown.forEach((u) => u.unsubscribe());
};

View File

@ -1,10 +1,19 @@
import { AnyAction, createAction } from '@reduxjs/toolkit';
import { AbsoluteTimeRange, dateTimeForTimeZone, LoadingState, RawTimeRange, TimeRange } from '@grafana/data';
import {
AbsoluteTimeRange,
AppEvents,
dateTimeForTimeZone,
LoadingState,
RawTimeRange,
TimeRange,
} from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { RefreshPicker } from '@grafana/ui';
import { t } from '@grafana/ui/src/utils/i18n';
import appEvents from 'app/core/app_events';
import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore';
import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { sortLogsResult } from 'app/features/logs/utils';
import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
@ -166,6 +175,41 @@ export function zoomOut(scale: number): ThunkResult<void> {
});
}
export function copyTimeRangeToClipboard(): ThunkResult<void> {
return (dispatch, getState) => {
const range = getState().explore.panes[Object.keys(getState().explore.panes)[0]]!.range.raw;
navigator.clipboard.writeText(JSON.stringify(range));
appEvents.emit(AppEvents.alertSuccess, [
t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'),
]);
};
}
export function pasteTimeRangeFromClipboard(): ThunkResult<void> {
return async (dispatch, getState) => {
const { range, isError } = await getCopiedTimeRange();
if (isError === true) {
appEvents.emit(AppEvents.alertError, [
t('time-picker.copy-paste.default-error-title', 'Invalid time range'),
t('time-picker.copy-paste.default-error-message', `{{error}} is not a valid time range`, { error: range }),
]);
return;
}
const panesSynced = getState().explore.syncedTimes;
if (panesSynced) {
dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: range }));
dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[1], rawRange: range }));
return;
}
dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: range }));
};
}
/**
* Reducer for an Explore area, to be used by the global Explore reducer.
*/

View File

@ -160,6 +160,18 @@ export class ShiftTimeEvent extends BusEventWithPayload<ShiftTimeEventPayload> {
static type = 'shift-time';
}
export class CopyTimeEvent extends BusEventBase {
static type = 'copy-time';
}
interface PasteTimeEventPayload {
updateUrl?: boolean;
}
export class PasteTimeEvent extends BusEventWithPayload<PasteTimeEventPayload> {
static type = 'paste-time';
}
interface AbsoluteTimeEventPayload {
updateUrl: boolean;
}

View File

@ -586,6 +586,7 @@
"shortcuts-description": {
"change-theme": "Thema ändern",
"collapse-all-rows": "Alle Zeilen einklappen",
"copy-time-range": "",
"dashboard-settings": "Dashboard-Einstellungen",
"duplicate-panel": "Fenster duplizieren",
"exit-edit/setting-views": "Ansicht beenden/einstellen",
@ -599,6 +600,7 @@
"move-time-range-forward": "Zeitbereich nach vorne verschieben",
"open-search": "Suche öffnen",
"open-shared-modal": "Geteilter Fenstermodus öffnen",
"paste-time-range": "",
"refresh-all-panels": "Alle Fenster aktualisieren",
"remove-panel": "Fenster entfernen",
"save-dashboard": "Dashboard speichern",
@ -1394,6 +1396,13 @@
"empty-recent-list-info": "Es sieht so aus, als hätten Sie diesen Zeit-Auswähler noch nie benutzt. Sobald Sie einige Zeitintervalle eingeben, werden hier die zuletzt verwendeten Intervalle angezeigt.",
"filter-placeholder": "Schnellbereiche suchen"
},
"copy-paste": {
"copy-success-message": "",
"default-error-message": "",
"default-error-title": "",
"tooltip-copy": "",
"tooltip-paste": ""
},
"footer": {
"change-settings-button": "Zeiteinstellungen ändern",
"fiscal-year-option": "Geschäftsjahr",

View File

@ -586,6 +586,7 @@
"shortcuts-description": {
"change-theme": "Change theme",
"collapse-all-rows": "Collapse all rows",
"copy-time-range": "Copy time range",
"dashboard-settings": "Dashboard settings",
"duplicate-panel": "Duplicate Panel",
"exit-edit/setting-views": "Exit edit/setting views",
@ -599,6 +600,7 @@
"move-time-range-forward": "Move time range forward",
"open-search": "Open search",
"open-shared-modal": "Open Panel Share Modal",
"paste-time-range": "Paste time range",
"refresh-all-panels": "Refresh all panels",
"remove-panel": "Remove Panel",
"save-dashboard": "Save dashboard",
@ -1394,6 +1396,13 @@
"empty-recent-list-info": "It looks like you haven't used this time picker before. As soon as you enter some time intervals, recently used intervals will appear here.",
"filter-placeholder": "Search quick ranges"
},
"copy-paste": {
"copy-success-message": "Time range copied to clipboard",
"default-error-message": "{{error}} is not a valid time range",
"default-error-title": "Invalid time range",
"tooltip-copy": "Copy time range to clipboard",
"tooltip-paste": "Paste time range"
},
"footer": {
"change-settings-button": "Change time settings",
"fiscal-year-option": "Fiscal year",

View File

@ -591,6 +591,7 @@
"shortcuts-description": {
"change-theme": "Cambiar tema",
"collapse-all-rows": "Contraer todas las filas",
"copy-time-range": "",
"dashboard-settings": "Ajustes del panel de control",
"duplicate-panel": "Duplicar panel",
"exit-edit/setting-views": "Salir de las opciones de edición/ajustes",
@ -604,6 +605,7 @@
"move-time-range-forward": "Mover el rango de tiempo hacia delante",
"open-search": "Abrir búsqueda",
"open-shared-modal": "Abrir el modo de panel compartido",
"paste-time-range": "",
"refresh-all-panels": "Actualizar todos los paneles",
"remove-panel": "Eliminar panel",
"save-dashboard": "Guardar panel de control",
@ -1400,6 +1402,13 @@
"empty-recent-list-info": "Parece que no ha utilizado antes este selector de tiempo. En cuanto introduzca algún intervalo de tiempo, los intervalos que haya usado recientemente aparecerán aquí.",
"filter-placeholder": "Buscar intervalos rápidos"
},
"copy-paste": {
"copy-success-message": "",
"default-error-message": "",
"default-error-title": "",
"tooltip-copy": "",
"tooltip-paste": ""
},
"footer": {
"change-settings-button": "Cambiar ajustes de tiempo",
"fiscal-year-option": "Ejercicio fiscal",

View File

@ -591,6 +591,7 @@
"shortcuts-description": {
"change-theme": "Modifier le thème",
"collapse-all-rows": "Réduire toutes les lignes",
"copy-time-range": "",
"dashboard-settings": "Paramètres du tableau de bord",
"duplicate-panel": "Dupliquer le panneau",
"exit-edit/setting-views": "Quitter les affichages de modification/configuration",
@ -604,6 +605,7 @@
"move-time-range-forward": "Avancer la plage de temps",
"open-search": "Ouvrir la recherche",
"open-shared-modal": "Ouvrir le panneau de partage modal",
"paste-time-range": "",
"refresh-all-panels": "Actualiser tous les panneaux",
"remove-panel": "Retirer le panneau",
"save-dashboard": "Enregistrer le tableau de bord",
@ -1400,6 +1402,13 @@
"empty-recent-list-info": "Il semble que vous n'ayez jamais utilisé ce sélecteur de temps dans le passé. Lorsque vous commencerez à utiliser des plages de temps, celles récemment utilisées apparaîtront ici.",
"filter-placeholder": "Rechercher dans les plages rapides"
},
"copy-paste": {
"copy-success-message": "",
"default-error-message": "",
"default-error-title": "",
"tooltip-copy": "",
"tooltip-paste": ""
},
"footer": {
"change-settings-button": "Modifier les paramètres de l'heure",
"fiscal-year-option": "Exercice fiscal",

View File

@ -586,6 +586,7 @@
"shortcuts-description": {
"change-theme": "Cĥäʼnģę ŧĥęmę",
"collapse-all-rows": "Cőľľäpşę äľľ řőŵş",
"copy-time-range": "Cőpy ŧįmę řäʼnģę",
"dashboard-settings": "Đäşĥþőäřđ şęŧŧįʼnģş",
"duplicate-panel": "Đūpľįčäŧę Päʼnęľ",
"exit-edit/setting-views": "Ēχįŧ ęđįŧ/şęŧŧįʼnģ vįęŵş",
@ -599,6 +600,7 @@
"move-time-range-forward": "Mővę ŧįmę řäʼnģę ƒőřŵäřđ",
"open-search": "Øpęʼn şęäřčĥ",
"open-shared-modal": "Øpęʼn Päʼnęľ Ŝĥäřę Mőđäľ",
"paste-time-range": "Päşŧę ŧįmę řäʼnģę",
"refresh-all-panels": "Ŗęƒřęşĥ äľľ päʼnęľş",
"remove-panel": "Ŗęmővę Päʼnęľ",
"save-dashboard": "Ŝävę đäşĥþőäřđ",
@ -1394,6 +1396,13 @@
"empty-recent-list-info": "Ĩŧ ľőőĸş ľįĸę yőū ĥävęʼn'ŧ ūşęđ ŧĥįş ŧįmę pįčĸęř þęƒőřę. Åş şőőʼn äş yőū ęʼnŧęř şőmę ŧįmę įʼnŧęřväľş, řęčęʼnŧľy ūşęđ įʼnŧęřväľş ŵįľľ äppęäř ĥęřę.",
"filter-placeholder": "Ŝęäřčĥ qūįčĸ řäʼnģęş"
},
"copy-paste": {
"copy-success-message": "Ŧįmę řäʼnģę čőpįęđ ŧő čľįpþőäřđ",
"default-error-message": "{{error}} įş ʼnőŧ ä väľįđ ŧįmę řäʼnģę",
"default-error-title": "Ĩʼnväľįđ ŧįmę řäʼnģę",
"tooltip-copy": "Cőpy ŧįmę řäʼnģę ŧő čľįpþőäřđ",
"tooltip-paste": "Päşŧę ŧįmę řäʼnģę"
},
"footer": {
"change-settings-button": "Cĥäʼnģę ŧįmę şęŧŧįʼnģş",
"fiscal-year-option": "Fįşčäľ yęäř",

View File

@ -581,6 +581,7 @@
"shortcuts-description": {
"change-theme": "更改主题",
"collapse-all-rows": "折叠所有行",
"copy-time-range": "",
"dashboard-settings": "仪表板设置",
"duplicate-panel": "复制面板",
"exit-edit/setting-views": "退出编辑/设置视图",
@ -594,6 +595,7 @@
"move-time-range-forward": "向前移动时间范围",
"open-search": "打开搜索",
"open-shared-modal": "打开面板分享模式",
"paste-time-range": "",
"refresh-all-panels": "刷新所有面板",
"remove-panel": "删除面板",
"save-dashboard": "保存仪表板",
@ -1388,6 +1390,13 @@
"empty-recent-list-info": "看起来您之前没有使用过这个时间选择器。一旦您输入了某些时间间隔,最近使用的间隔就会出现在此处。",
"filter-placeholder": "搜索快速范围"
},
"copy-paste": {
"copy-success-message": "",
"default-error-message": "",
"default-error-title": "",
"tooltip-copy": "",
"tooltip-paste": ""
},
"footer": {
"change-settings-button": "更改时间设置",
"fiscal-year-option": "财政年度",