From e458db8bf4f789d4d19f1b37f0263f910c8d036c Mon Sep 17 00:00:00 2001 From: maxiadlovskii Date: Mon, 19 Jun 2023 11:26:56 +0200 Subject: [PATCH] Create time range quick access list for multiple time range types (#15484) * Create QuickAccessTimeRangeForm * Add context for TimeRange Input * Add summary and sortable list. Connect with BE * Replace list. Add button to quick adding to the list * fix title * Adding type definitions. * add filtrated list. Add presets to relative * fix presets to relative * Unify button naming. * Unify positioning of preset value and description on configuration page. * Make sure preset list items do not remount when chaning content. * Remove not needed formik usage. * Fix prop type error. * fix timezone problem on update absoluter timereinge in configuration * Only update presets on configuration page after submitting changes. * fix TimeRangeInput test * Add presets to other tabs * Conversion from timerange options to timerange presets * Migration instead of temporary solution. IDs introduced. All fields of timerange presets required. More detailed information on a reason of failed config save. * ignore time limits when create a new timerange in configuration * fix tsc * fix test * add test to RangePresetDropdown * change icon and submit component in TimeRangeAddToQuickListButton * add test. check show/hide quick access button * Improve accessibility * add test for TimeRangeAddToQuickListButton * Fixing attributes * Fixing attributes * fix issue with ariaLabel * fix test issues * Add QuickAccessTimeRangeForm test * small improvements * small improvements * update telemetry * Add changelog * fix texting when no available presets * refactoring * refactoring * Make quick access time range description required. --------- Co-authored-by: Linus Pahl Co-authored-by: Lukasz Kaminski --- changelog/unreleased/issue-15426.toml | 4 + .../timerangepresets/TimerangePreset.java | 22 ++- .../PeriodToRelativeRangeConverter.java | 36 ++++ ...geOptionsToTimerangePresetsConversion.java | 53 ++++++ .../graylog2/migrations/MigrationsModule.java | 1 + ...ateTimerangeOptionsToTimerangePresets.java | 98 ++++++++++ .../system/ClusterConfigResource.java | 2 +- .../PeriodToRelativeRangeConverterTest.java | 80 ++++++++ ...tionsToTimerangePresetsConversionTest.java | 107 +++++++++++ .../src/components/common/FormSubmit.tsx | 3 + .../src/components/common/ModalSubmit.tsx | 3 + .../QuickAccessTimeRangeForm.test.tsx | 123 ++++++++++++ .../QuickAccessTimeRangeForm.tsx | 175 ++++++++++++++++++ .../QuickAccessTimeRangeOptionsSummary.tsx | 77 ++++++++ .../configurations/SearchesConfig.tsx | 54 ++++-- .../src/components/search/SearchConfig.ts | 3 + .../src/hooks/useSearchConfiguration.ts | 5 +- .../src/logic/telemetry/TelemetryContext.tsx | 2 +- .../contexts/TimeRangeInputSettingsContext.ts | 41 ++++ .../messagelist/MessageActions.test.tsx | 1 + .../searchbar/RangePresetDropdown.test.tsx | 65 +++++-- .../searchbar/RangePresetDropdown.tsx | 99 ++++++---- .../searchbar/RefreshControls.test.tsx | 1 + .../components/searchbar/TimeRangeDisplay.tsx | 4 +- .../TimeRangeDropdownButton.test.tsx | 8 +- .../searchbar/TimeRangeDropdownButton.tsx | 67 +++---- .../searchbar/TimeRangeInput.test.tsx | 30 ++- .../components/searchbar/TimeRangeInput.tsx | 21 ++- .../date-time-picker/RelativeRangeSelect.tsx | 37 +--- .../date-time-picker/TabAbsoluteTimeRange.tsx | 122 ++++++------ .../date-time-picker/TabKeywordTimeRange.tsx | 22 ++- .../date-time-picker/TabPresetDropdown.tsx | 46 +++++ .../date-time-picker/TabRelativeTimeRange.tsx | 66 ++++--- .../TimeRangeAddToQuickListButton.test.tsx | 172 +++++++++++++++++ .../TimeRangeAddToQuickListButton.tsx | 149 +++++++++++++++ .../date-time-picker/TimeRangeDropdown.tsx | 8 +- .../date-time-picker/TimeRangeLivePreview.tsx | 29 +-- .../test/fixtures/searchClusterConfig.ts | 115 ++++++++++++ 38 files changed, 1690 insertions(+), 261 deletions(-) create mode 100644 changelog/unreleased/issue-15426.toml create mode 100644 graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/conversion/PeriodToRelativeRangeConverter.java create mode 100644 graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/conversion/TimerangeOptionsToTimerangePresetsConversion.java create mode 100644 graylog2-server/src/main/java/org/graylog2/migrations/V202305221200_MigrateTimerangeOptionsToTimerangePresets.java create mode 100644 graylog2-server/src/test/java/org/graylog2/indexer/searches/timerangepresets/conversion/PeriodToRelativeRangeConverterTest.java create mode 100644 graylog2-server/src/test/java/org/graylog2/indexer/searches/timerangepresets/conversion/TimerangeOptionsToTimerangePresetsConversionTest.java create mode 100644 graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeForm.test.tsx create mode 100644 graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeForm.tsx create mode 100644 graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeOptionsSummary.tsx create mode 100644 graylog2-web-interface/src/views/components/contexts/TimeRangeInputSettingsContext.ts create mode 100644 graylog2-web-interface/src/views/components/searchbar/date-time-picker/TabPresetDropdown.tsx create mode 100644 graylog2-web-interface/src/views/components/searchbar/date-time-picker/TimeRangeAddToQuickListButton.test.tsx create mode 100644 graylog2-web-interface/src/views/components/searchbar/date-time-picker/TimeRangeAddToQuickListButton.tsx diff --git a/changelog/unreleased/issue-15426.toml b/changelog/unreleased/issue-15426.toml new file mode 100644 index 000000000000..aeae2f528825 --- /dev/null +++ b/changelog/unreleased/issue-15426.toml @@ -0,0 +1,4 @@ +type = "added" +message = "Create time range quick access list for multiple time range types" + +pulls = ["15484"] diff --git a/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/TimerangePreset.java b/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/TimerangePreset.java index e378748654e2..5d594895c947 100644 --- a/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/TimerangePreset.java +++ b/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/TimerangePreset.java @@ -16,9 +16,27 @@ */ package org.graylog2.indexer.searches.timerangepresets; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import org.graylog2.plugin.indexer.searches.timeranges.TimeRange; -public record TimerangePreset(@JsonProperty("timerange") TimeRange timeRange, - @JsonProperty("description") String description) { +import java.util.UUID; + +public record TimerangePreset( + @JsonProperty(value = "id", required = true) String id, + @JsonProperty(value = "timerange", required = true) TimeRange timeRange, + @JsonProperty(value = "description", required = true) String description) { + + @JsonCreator + public TimerangePreset(@JsonProperty(value = "id", required = true) String id, + @JsonProperty(value = "timerange", required = true) TimeRange timeRange, + @JsonProperty(value = "description", required = true) String description) { + this.id = id; + this.timeRange = timeRange; + this.description = description; + } + + public TimerangePreset(TimeRange timeRange, String description) { + this(UUID.randomUUID().toString(), timeRange, description); + } } diff --git a/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/conversion/PeriodToRelativeRangeConverter.java b/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/conversion/PeriodToRelativeRangeConverter.java new file mode 100644 index 000000000000..e4f5aa05ddc9 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/conversion/PeriodToRelativeRangeConverter.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.indexer.searches.timerangepresets.conversion; + +import org.graylog2.plugin.indexer.searches.timeranges.RelativeRange; +import org.joda.time.Period; + +import java.util.function.Function; + +public class PeriodToRelativeRangeConverter implements Function { + + @Override + public RelativeRange apply(final Period period) { + if (period != null) { + return RelativeRange.Builder.builder() + .from(period.toStandardSeconds().getSeconds()) + .build(); + } else { + return null; + } + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/conversion/TimerangeOptionsToTimerangePresetsConversion.java b/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/conversion/TimerangeOptionsToTimerangePresetsConversion.java new file mode 100644 index 000000000000..42cf038f51b7 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/indexer/searches/timerangepresets/conversion/TimerangeOptionsToTimerangePresetsConversion.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.indexer.searches.timerangepresets.conversion; + + +import org.graylog2.indexer.searches.timerangepresets.TimerangePreset; +import org.graylog2.plugin.indexer.searches.timeranges.RelativeRange; +import org.joda.time.Period; + +import javax.inject.Inject; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class TimerangeOptionsToTimerangePresetsConversion { + + private final Function periodConverter; + + @Inject + public TimerangeOptionsToTimerangePresetsConversion(final PeriodToRelativeRangeConverter periodConverter) { + this.periodConverter = periodConverter; + } + + public List convert(final Map timerangeOptions) { + if (timerangeOptions == null) { + return List.of(); + } + return timerangeOptions.entrySet() + .stream() + .map(entry -> new TimerangePreset( + periodConverter.apply(entry.getKey()), + entry.getValue()) + ) + .collect(Collectors.toList()); + } + + +} diff --git a/graylog2-server/src/main/java/org/graylog2/migrations/MigrationsModule.java b/graylog2-server/src/main/java/org/graylog2/migrations/MigrationsModule.java index 55194f8e9729..e8f468fc50c2 100644 --- a/graylog2-server/src/main/java/org/graylog2/migrations/MigrationsModule.java +++ b/graylog2-server/src/main/java/org/graylog2/migrations/MigrationsModule.java @@ -64,6 +64,7 @@ protected void configure() { addMigration(V20230220095500_MigrateStartPageObjectReferencesToGRNbyRemoval.class); addMigration(V20230213160000_EncryptedInputConfigMigration.class); addMigration(V20230210102500_UniqueUserMigration.class); + addMigration(V202305221200_MigrateTimerangeOptionsToTimerangePresets.class); addMigration(V20230523160600_PopulateEventDefinitionState.class); addMigration(V20230531135500_MigrateRemoveObsoleteItemsFromGrantsCollection.class); addMigration(V20230601104500_AddSourcesPageV2.class); diff --git a/graylog2-server/src/main/java/org/graylog2/migrations/V202305221200_MigrateTimerangeOptionsToTimerangePresets.java b/graylog2-server/src/main/java/org/graylog2/migrations/V202305221200_MigrateTimerangeOptionsToTimerangePresets.java new file mode 100644 index 000000000000..727f050c4d11 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/migrations/V202305221200_MigrateTimerangeOptionsToTimerangePresets.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.migrations; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.google.auto.value.AutoValue; +import org.graylog.autovalue.WithBeanGetter; +import org.graylog2.indexer.searches.SearchesClusterConfig; +import org.graylog2.indexer.searches.timerangepresets.TimerangePreset; +import org.graylog2.indexer.searches.timerangepresets.conversion.TimerangeOptionsToTimerangePresetsConversion; +import org.graylog2.plugin.cluster.ClusterConfigService; +import org.joda.time.Period; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class V202305221200_MigrateTimerangeOptionsToTimerangePresets extends Migration { + + private static final Logger LOG = LoggerFactory.getLogger(V202305221200_MigrateTimerangeOptionsToTimerangePresets.class); + + private final ClusterConfigService clusterConfigService; + private final TimerangeOptionsToTimerangePresetsConversion conversion; + + @Inject + public V202305221200_MigrateTimerangeOptionsToTimerangePresets(final ClusterConfigService clusterConfigService, + final TimerangeOptionsToTimerangePresetsConversion conversion) { + this.clusterConfigService = clusterConfigService; + this.conversion = conversion; + } + + @Override + public ZonedDateTime createdAt() { + return ZonedDateTime.parse("2023-05-22T12:00:00Z"); + } + + @Override + public void upgrade() { + if (clusterConfigService.get(V202305221200_MigrateTimerangeOptionsToTimerangePresets.MigrationCompleted.class) != null) { + LOG.debug("Migration already completed."); + return; + } + + final SearchesClusterConfig searchesClusterConfig = clusterConfigService.get(SearchesClusterConfig.class); + if (searchesClusterConfig != null) { + final Map relativeTimerangeOptions = searchesClusterConfig.relativeTimerangeOptions(); + if (relativeTimerangeOptions != null && !relativeTimerangeOptions.isEmpty()) { + final List converted = conversion.convert(relativeTimerangeOptions); + List quickAccessTimerangePresets = searchesClusterConfig.quickAccessTimerangePresets(); + List newQuickAccessTimerangePresets = new ArrayList<>(); + if (quickAccessTimerangePresets != null) { + newQuickAccessTimerangePresets.addAll(quickAccessTimerangePresets); + } + newQuickAccessTimerangePresets.addAll(converted); + + final SearchesClusterConfig newConfig = searchesClusterConfig + .toBuilder() + .quickAccessTimerangePresets(newQuickAccessTimerangePresets) + .build(); + clusterConfigService.write(newConfig); + clusterConfigService.write(MigrationCompleted.create()); + LOG.info("Migration created " + relativeTimerangeOptions.size() + " new entries in quickAccessTimerangePresets list, based on relativeTimerangeOptions list"); + return; + } + } + LOG.info("Migration was not needed, no relativeTimerangeOptions data to move"); + } + + @JsonAutoDetect + @AutoValue + @WithBeanGetter + public static abstract class MigrationCompleted { + + @JsonCreator + public static V202305221200_MigrateTimerangeOptionsToTimerangePresets.MigrationCompleted create() { + return new AutoValue_V202305221200_MigrateTimerangeOptionsToTimerangePresets_MigrationCompleted(); + } + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/ClusterConfigResource.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/ClusterConfigResource.java index 813bf771127c..94af7ad137c5 100644 --- a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/ClusterConfigResource.java +++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/ClusterConfigResource.java @@ -158,7 +158,7 @@ private Object parseConfigObject(String configClass, InputStream body, Class try { object = objectMapper.readValue(body, cls); } catch (Exception e) { - final String msg = "Couldn't parse cluster configuration \"" + configClass + "\"."; + final String msg = "Couldn't parse cluster configuration \"" + configClass + "\". The problem was : " + e.getMessage(); LOG.error(msg, e); throw new BadRequestException(msg); } diff --git a/graylog2-server/src/test/java/org/graylog2/indexer/searches/timerangepresets/conversion/PeriodToRelativeRangeConverterTest.java b/graylog2-server/src/test/java/org/graylog2/indexer/searches/timerangepresets/conversion/PeriodToRelativeRangeConverterTest.java new file mode 100644 index 000000000000..ac1f56aa9932 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog2/indexer/searches/timerangepresets/conversion/PeriodToRelativeRangeConverterTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.indexer.searches.timerangepresets.conversion; + +import org.graylog2.plugin.indexer.searches.timeranges.RelativeRange; +import org.joda.time.Period; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNull; + +class PeriodToRelativeRangeConverterTest { + + private PeriodToRelativeRangeConverter converter; + + @BeforeEach + void setUp() { + converter = new PeriodToRelativeRangeConverter(); + } + + @Test + void testReturnsNullOnNullInput() { + assertNull(converter.apply(null)); + } + + @Test + void testSecondConversion() { + final RelativeRange result = converter.apply(Period.seconds(5)); + verifyResult(result, 5); + } + + @Test + void testMinuteConversion() { + final RelativeRange result = converter.apply(Period.minutes(30)); + verifyResult(result, 1800); + } + + @Test + void testHourConversion() { + final RelativeRange result = converter.apply(Period.hours(2)); + verifyResult(result, 7200); + } + + @Test + void testDayConversion() { + final RelativeRange result = converter.apply(Period.days(2)); + verifyResult(result, 172800); + } + + @Test + void testMixedPeriodConversion() { + final RelativeRange result = converter.apply(Period.hours(1).plusMinutes(10).plusSeconds(7)); + verifyResult(result, 4207); + } + + private void verifyResult(final RelativeRange result, final int expectedFromField) { + assertThat(result) + .isNotNull() + .satisfies(range -> { + assertThat(range.range()).isEmpty(); + assertThat(range.from()).isPresent().hasValue(expectedFromField); + }); + } + +} diff --git a/graylog2-server/src/test/java/org/graylog2/indexer/searches/timerangepresets/conversion/TimerangeOptionsToTimerangePresetsConversionTest.java b/graylog2-server/src/test/java/org/graylog2/indexer/searches/timerangepresets/conversion/TimerangeOptionsToTimerangePresetsConversionTest.java new file mode 100644 index 000000000000..75b2a5b3fb2d --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog2/indexer/searches/timerangepresets/conversion/TimerangeOptionsToTimerangePresetsConversionTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.indexer.searches.timerangepresets.conversion; + +import org.graylog2.indexer.searches.timerangepresets.TimerangePreset; +import org.graylog2.plugin.indexer.searches.timeranges.RelativeRange; +import org.joda.time.Period; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +class TimerangeOptionsToTimerangePresetsConversionTest { + + private TimerangeOptionsToTimerangePresetsConversion toTest; + + private PeriodToRelativeRangeConverter periodConverter; + + @BeforeEach + void setUp() { + periodConverter = mock(PeriodToRelativeRangeConverter.class); + toTest = new TimerangeOptionsToTimerangePresetsConversion(periodConverter); + } + + @Test + void testConversionReturnsEmptyListOnEmptyInput() { + assertThat(toTest.convert(Map.of())).isEmpty(); + } + + @Test + void testConversionReturnsEmptyListOnNullInput() { + assertThat(toTest.convert(null)).isEmpty(); + } + + @Test + void testConversionCombinesDescriptionAndProperPeriod() { + RelativeRange rangeFromConversion = RelativeRange.allTime(); + final Period periodToConvert = Period.years(6000); + doReturn(rangeFromConversion).when(periodConverter).apply(periodToConvert); + final List result = toTest.convert(Map.of(periodToConvert, "Long, long time")); + + assertThat(result) + .isNotNull() + .hasSize(1) + .extracting(TimerangePreset::timeRange, TimerangePreset::description) + .containsOnly( + tuple(rangeFromConversion, "Long, long time") + ); + + } + + @Test + void testConversionOnSomeDefaultRelativeTimerangeOptions() { + Map defaults = new LinkedHashMap( + Map.of( + Period.minutes(15), "15 minutes", + Period.hours(8), "8 hours", + Period.days(1), "1 day" + ) + ); + + doCallRealMethod().when(periodConverter).apply(any(Period.class)); + final List result = toTest.convert(defaults); + + assertThat(result) + .isNotNull() + .hasSize(3) + .extracting(TimerangePreset::timeRange, TimerangePreset::description) + .containsExactlyInAnyOrder( + tuple(RelativeRange.Builder.builder() + .from(15 * 60) + .build(), "15 minutes"), + tuple(RelativeRange.Builder.builder() + .from(8 * 60 * 60) + .build(), "8 hours"), + tuple(RelativeRange.Builder.builder() + .from(24 * 60 * 60) + .build(), "1 day") + ); + + } + + +} diff --git a/graylog2-web-interface/src/components/common/FormSubmit.tsx b/graylog2-web-interface/src/components/common/FormSubmit.tsx index 88afeb36ebdc..4b9956a937d1 100644 --- a/graylog2-web-interface/src/components/common/FormSubmit.tsx +++ b/graylog2-web-interface/src/components/common/FormSubmit.tsx @@ -83,6 +83,7 @@ const FormSubmit = (props: Props) => { disabled={disabledSubmit || (isAsyncSubmit && props.isSubmitting)} form={formId} title={submitButtonText} + aria-label={submitButtonText} type={submitButtonType} onClick={onSubmit}> {(submitIcon && !(isAsyncSubmit && props.isSubmitting)) && } @@ -93,6 +94,8 @@ const FormSubmit = (props: Props) => { diff --git a/graylog2-web-interface/src/components/common/ModalSubmit.tsx b/graylog2-web-interface/src/components/common/ModalSubmit.tsx index f03763e92cf9..ca33a0582a72 100644 --- a/graylog2-web-interface/src/components/common/ModalSubmit.tsx +++ b/graylog2-web-interface/src/components/common/ModalSubmit.tsx @@ -88,6 +88,8 @@ const ModalSubmit = (props: Props) => { @@ -97,6 +99,7 @@ const ModalSubmit = (props: Props) => { disabled={disabledSubmit || (isAsyncSubmit && props.isSubmitting)} form={formId} title={submitButtonText} + aria-label={submitButtonText} type={submitButtonType} onClick={onSubmit}> {(submitIcon && !(isAsyncSubmit && props.isSubmitting)) && <> } diff --git a/graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeForm.test.tsx b/graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeForm.test.tsx new file mode 100644 index 000000000000..c09ee7683d1b --- /dev/null +++ b/graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeForm.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import type { DebouncedFunc } from 'lodash'; +import { fireEvent, render, screen, within, waitFor } from 'wrappedTestingLibrary'; +import debounce from 'lodash/debounce'; +import Immutable from 'immutable'; +import { Formik } from 'formik'; + +import { StoreMock as MockStore, asMock } from 'helpers/mocking'; +import useSearchConfiguration from 'hooks/useSearchConfiguration'; +import mockSearchesClusterConfig from 'fixtures/searchClusterConfig'; + +import QuickAccessTimeRangeForm from './QuickAccessTimeRangeForm'; + +jest.mock('views/stores/SearchConfigStore', () => ({ + SearchConfigStore: MockStore(['getInitialState', () => ({ searchesClusterConfig: mockSearchesClusterConfig })]), + SearchConfigActions: { + refresh: jest.fn(() => Promise.resolve()), + }, +})); + +jest.mock('hooks/useSearchConfiguration', () => jest.fn()); +jest.mock('lodash/debounce', () => jest.fn()); +jest.mock('logic/generateId', () => jest.fn(() => 'tr-id-3')); + +const mockOnUpdate = jest.fn(); + +const renderForm = () => render( + {}}> + + ); + +describe('QuickAccessTimeRangeForm', () => { + beforeEach(() => { + asMock(useSearchConfiguration).mockReturnValue({ + config: mockSearchesClusterConfig, + refresh: jest.fn(), + }); + + asMock(debounce as DebouncedFunc<(...args: any) => any>).mockImplementation((fn) => fn); + }); + + it('render all items', async () => { + renderForm(); + await screen.findByText('5 minutes ago'); + await screen.findByText('10 minutes ago'); + }); + + it('remove action trigger onUpdate', async () => { + renderForm(); + const timerangeItem = await screen.findByTestId('time-range-preset-tr-id-1'); + const removeButton = await within(timerangeItem).findByTitle('Remove preset'); + + fireEvent.click(removeButton); + + expect(mockOnUpdate).toHaveBeenCalledWith(Immutable.List([ + { description: 'TimeRange2', id: 'tr-id-2', timerange: { from: 600, type: 'relative' } }, + ])); + }); + + it('add action trigger onUpdate', async () => { + renderForm(); + const addItemButton = await screen.findByLabelText('Add quick access timerange'); + + fireEvent.click(addItemButton); + + expect(mockOnUpdate).toHaveBeenCalledWith(Immutable.List([ + { description: 'TimeRange1', id: 'tr-id-1', timerange: { from: 300, type: 'relative' } }, + { description: 'TimeRange2', id: 'tr-id-2', timerange: { from: 600, type: 'relative' } }, + { description: '', id: 'tr-id-3', timerange: { from: 300, type: 'relative' } }, + ])); + }); + + it('edit description action trigger onUpdate', async () => { + renderForm(); + const timerangeItem = await screen.findByTestId('time-range-preset-tr-id-1'); + const descriptionInput = await within(timerangeItem).findByTitle('Time range preset description'); + + fireEvent.change(descriptionInput, { target: { value: 'TimeRange1 changed' } }); + + expect(mockOnUpdate).toHaveBeenCalledWith(Immutable.List([ + { description: 'TimeRange1 changed', id: 'tr-id-1', timerange: { from: 300, type: 'relative' } }, + { description: 'TimeRange2', id: 'tr-id-2', timerange: { from: 600, type: 'relative' } }, + ])); + }); + + it('edit timerange action trigger onUpdate', async () => { + renderForm(); + const timerangeItem = await screen.findByTestId('time-range-preset-tr-id-1'); + const timerangeInput = await within(timerangeItem).findByText('5 minutes ago'); + await fireEvent.click(timerangeInput); + await screen.findByText(/search time range/i); + const fromInput = await screen.findByTitle('Set the from value'); + await fireEvent.change(fromInput, { target: { value: 15 } }); + const submit = await screen.findByTitle('Update time range'); + await fireEvent.click(submit); + + await waitFor(() => expect(mockOnUpdate).toHaveBeenCalledWith(Immutable.List([ + { description: 'TimeRange1', id: 'tr-id-1', timerange: { from: 900, type: 'relative' } }, + { description: 'TimeRange2', id: 'tr-id-2', timerange: { from: 600, type: 'relative' } }, + ]))); + }); +}); diff --git a/graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeForm.tsx b/graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeForm.tsx new file mode 100644 index 000000000000..6bac9683e4bf --- /dev/null +++ b/graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeForm.tsx @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import type { Dispatch } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import moment from 'moment/moment'; +import styled from 'styled-components'; +import Immutable from 'immutable'; +import debounce from 'lodash/debounce'; + +import { Button, Input } from 'components/bootstrap'; +import { Icon, SortableList } from 'components/common'; +import type { TimeRange } from 'views/logic/queries/Query'; +import { useStore } from 'stores/connect'; +import { SearchConfigStore } from 'views/stores/SearchConfigStore'; +import TimeRangeInput from 'views/components/searchbar/TimeRangeInput'; +import TimeRangeInputSettingsContext from 'views/components/contexts/TimeRangeInputSettingsContext'; +import generateId from 'logic/generateId'; + +export type QuickAccessTimeRange = { + timerange: TimeRange, + description: string, + id: string, +} + +const ItemWrapper = styled.div` + display: flex; + align-items: stretch; + gap: 5px; + flex-grow: 1; +`; + +const StyledInput = styled(Input)` + margin-bottom: 0; + width: 200px; +`; + +const IconWrapper = styled.div` + width: 40px; +`; + +const Description = styled.div` + display: flex; +`; + +type ItemProps = { + idx: number, + id: string, + timerange: TimeRange, + description: string, + onChange: (timerange: QuickAccessTimeRange, idx: number) => void, + onRemove: (idx: number) => void, + limitDuration: number, +} + +const contextSettings = { + showDropdownButton: false, + showRelativePresetsButton: false, + showAbsolutePresetsButton: false, + showKeywordPresetsButton: false, + showAddToQuickListButton: false, + ignoreLimitDurationInTimeRangeDropdown: true, +}; + +const QuickAccessTimeRangeFormItem = ({ idx, id, timerange, description, onChange, onRemove, limitDuration }: ItemProps) => { + const handleOnChangeRange = useCallback((newTimerange: TimeRange) => { + onChange({ timerange: newTimerange, description, id }, idx); + }, [description, id, idx, onChange]); + + const handleOnChangeDescription = useCallback((newDescription: string) => { + onChange({ timerange, description: newDescription, id }, idx); + }, [id, idx, onChange, timerange]); + + const handleOnRemove = useCallback(() => { + onRemove(idx); + }, [idx, onRemove]); + + const debounceHandleOnChangeDescription = debounce((value: string) => handleOnChangeDescription(value), 300); + + return ( + + + + debounceHandleOnChangeDescription(value)} + formGroupClassName="" /> + + + + + + ); +}; + +const QuickAccessTimeRangeForm = ({ options, onUpdate }: { + options: Immutable.List, + onUpdate: Dispatch> +}) => { + const onChange = useCallback((newPreset: QuickAccessTimeRange, idx: number) => { + const newState = options.set(idx, newPreset); + onUpdate(newState); + }, [onUpdate, options]); + + const { searchesClusterConfig: config } = useStore(SearchConfigStore); + const limitDuration = useMemo(() => moment.duration(config?.query_time_range_limit).asSeconds() ?? 0, [config?.query_time_range_limit]); + + const onRemove = useCallback((idx: number) => { + const newState = options.delete(idx); + onUpdate(newState); + }, [onUpdate, options]); + + const onMoveItem = useCallback((items: Array) => { + onUpdate(Immutable.List(items)); + }, [onUpdate]); + + const addTimeRange = useCallback(() => { + onUpdate(options.push({ + id: generateId(), + description: '', + timerange: { type: 'relative', from: 300 }, + })); + }, [onUpdate, options]); + + const customContentRender = useCallback(({ item: { id, description, timerange }, index }) => ( + + ), [limitDuration, onChange, onRemove]); + + return ( +
+ Quick Access Time Range Options + + Configure the available options for the quick access time range selector + +
+ + + +
+ +
+ ); +}; + +export default QuickAccessTimeRangeForm; diff --git a/graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeOptionsSummary.tsx b/graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeOptionsSummary.tsx new file mode 100644 index 000000000000..1b80acda2692 --- /dev/null +++ b/graylog2-web-interface/src/components/configurations/QuickAccessTimeRangeOptionsSummary.tsx @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import PropTypes from 'prop-types'; +import React from 'react'; +import styled from 'styled-components'; + +import assertUnreachable from 'logic/assertUnreachable'; +import type { QuickAccessTimeRange } from 'components/configurations/QuickAccessTimeRangeForm'; +import type { + KeywordTimeRange, + TimeRange, +} from 'views/logic/queries/Query'; +import { dateOutput } from 'views/components/searchbar/TimeRangeDisplay'; + +type Props = { options : Array}; + +const StyledDL = styled.dl` + && { + > span { + display: flex; + gap: 5px; + } + + dt { + white-space: nowrap; + flex-basis: 175px + } + dd { + margin: 0; + flex: 1 + } + } +`; + +export const getTimeRangeValueSummary = (timerange: TimeRange) => { + switch (timerange.type) { + case 'relative': + return `${dateOutput(timerange).from} - ${dateOutput(timerange).until}`; + case 'absolute': + return `${dateOutput(timerange).from} - ${dateOutput(timerange).until}`; + case 'keyword': + return (timerange as KeywordTimeRange).keyword; + default: + return assertUnreachable(timerange, 'Timerange type doesn\'t not exist'); + } +}; + +const QuickAccessTimeRangeOptionsSummary = ({ options }: Props) => ( + + {options.map(({ timerange, id, description }) => ( + +
{getTimeRangeValueSummary(timerange)}
+
{description}
+
+ ))} +
+); + +QuickAccessTimeRangeOptionsSummary.propTypes = { + options: PropTypes.object.isRequired, +}; + +export default QuickAccessTimeRangeOptionsSummary; diff --git a/graylog2-web-interface/src/components/configurations/SearchesConfig.tsx b/graylog2-web-interface/src/components/configurations/SearchesConfig.tsx index 9825e3658d3e..d838be6eaf25 100644 --- a/graylog2-web-interface/src/components/configurations/SearchesConfig.tsx +++ b/graylog2-web-interface/src/components/configurations/SearchesConfig.tsx @@ -15,8 +15,9 @@ * . */ import * as React from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import moment from 'moment'; +import Immutable from 'immutable'; import { useStore } from 'stores/connect'; import type { Store } from 'stores/StoreTypes'; @@ -29,16 +30,20 @@ import Spinner from 'components/common/Spinner'; import type { SearchConfig } from 'components/search'; import Select from 'components/common/Select/Select'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; - import 'moment-duration-format'; +import type { QuickAccessTimeRange } from 'components/configurations/QuickAccessTimeRangeForm'; +import QuickAccessTimeRangeForm from 'components/configurations/QuickAccessTimeRangeForm'; +import generateId from 'logic/generateId'; +import QuickAccessTimeRangeOptionsSummary from 'components/configurations/QuickAccessTimeRangeOptionsSummary'; +import { onInitializingTimerange, onSubmittingTimerange } from 'views/components/TimerangeForForm'; +import useUserDateTime from 'hooks/useUserDateTime'; +import type { DateTime, DateTimeFormats } from 'util/DateTime'; import TimeRangeOptionsForm from './TimeRangeOptionsForm'; import TimeRangeOptionsSummary from './TimeRangeOptionsSummary'; const queryTimeRangeLimitValidator = (milliseconds: number) => milliseconds >= 1; -const relativeTimeRangeValidator = (milliseconds: number, duration: string) => milliseconds >= 1 || duration === 'PT0S'; - const surroundingTimeRangeValidator = (milliseconds: number) => milliseconds >= 1; function autoRefreshTimeRangeValidator(milliseconds: number) { @@ -51,7 +56,14 @@ const buildTimeRangeOptions = (options: { [x: string]: string; }) => Object.keys type Option = { period: string, description: string }; +const mapQuickAccessBEData = (items: Array, formatTime: (time: DateTime, format?: DateTimeFormats) => string): Immutable.List => Immutable.List(items.map(({ timerange, description, id }) => { + const presetId = id ?? generateId(); + + return { description, id: presetId, timerange: onInitializingTimerange(timerange, formatTime) }; +})); + const SearchesConfig = () => { + const { userTimezone, formatTime } = useUserDateTime(); const isLimitEnabled = (config) => moment.duration(config?.query_time_range_limit).asMilliseconds() > 0; const [showConfigModal, setShowConfigModal] = useState(false); const [viewConfig, setViewConfig] = useState(undefined); @@ -63,13 +75,12 @@ const SearchesConfig = () => { const [surroundingFilterFieldsUpdate, setSurroundingFilterFieldsUpdate] = useState(undefined); const [analysisDisabledFieldsUpdate, setAnalysisDisabledFieldsUpdate] = useState(undefined); const [defaultAutoRefreshOptionUpdate, setDefaultAutoRefreshOptionUpdate] = useState(undefined); - + const [quickAccessTimeRangePresetsUpdated, setQuickAccessTimeRangePresetsUpdated] = useState>(undefined); const sendTelemetry = useSendTelemetry(); useEffect(() => { ConfigurationsActions.list(ConfigurationType.SEARCHES_CLUSTER_CONFIG).then(() => { const config = getConfig(ConfigurationType.SEARCHES_CLUSTER_CONFIG, configuration); - setViewConfig(config); setFormConfig(config); }); @@ -79,8 +90,8 @@ const SearchesConfig = () => { setFormConfig({ ...formConfig, [field]: newOptions }); }; - const onRelativeTimeRangeOptionsUpdate = (data: Array