From 974572763c0aa56b80bba76bee021d8ca2bbeceb Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 8 Aug 2022 17:43:53 -0400 Subject: [PATCH 01/98] fix(tests,history): specify initEntries for createMemoryHistory --- src/test/Recordings/ActiveRecordingsTable.test.tsx | 12 ++++++------ src/test/Recordings/ArchivedRecordingsTable.test.tsx | 3 +-- src/test/Rules/CreateRule.test.tsx | 3 +-- src/test/Rules/Rules.test.tsx | 5 ++--- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/test/Recordings/ActiveRecordingsTable.test.tsx b/src/test/Recordings/ActiveRecordingsTable.test.tsx index 94f42d4fd..87495d036 100644 --- a/src/test/Recordings/ActiveRecordingsTable.test.tsx +++ b/src/test/Recordings/ActiveRecordingsTable.test.tsx @@ -36,6 +36,7 @@ * SOFTWARE. */ import * as React from 'react'; +import { createMemoryHistory } from 'history'; import renderer, { act } from 'react-test-renderer'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -77,14 +78,12 @@ const mockLabelsNotification = { const mockStopNotification = { message: { target: mockConnectUrl, recording: mockRecording } } as NotificationMessage; const mockDeleteNotification = mockStopNotification; -const mockHistoryPush = jest.fn(); +const history = createMemoryHistory({initialEntries: ["/recordings"]}); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - useRouteMatch: () => ({ url: '/baseUrl' }), - useHistory: () => ({ - push: mockHistoryPush, - }), + useRouteMatch: () => ({ url: history.location.pathname }), + useHistory: () => history, })); jest.mock('@app/Recordings/RecordingFilters', () => { @@ -158,6 +157,7 @@ jest beforeEach(() => { mockRecording.metadata.labels = mockRecordingLabels; mockRecording.state = RecordingState.RUNNING; + history.go(-history.length); }); it('renders correctly', async () => { @@ -234,7 +234,7 @@ jest userEvent.click(screen.getByText('Create')); - expect(mockHistoryPush).toHaveBeenCalledWith('/baseUrl/create'); + expect(history.entries.map((entry) => entry.pathname)).toStrictEqual(["/recordings", "/recordings/create"]); }); it('archives the selected recording when Archive is clicked', () => { diff --git a/src/test/Recordings/ArchivedRecordingsTable.test.tsx b/src/test/Recordings/ArchivedRecordingsTable.test.tsx index 0a0adc966..e3c754e40 100644 --- a/src/test/Recordings/ArchivedRecordingsTable.test.tsx +++ b/src/test/Recordings/ArchivedRecordingsTable.test.tsx @@ -86,7 +86,7 @@ const mockDeleteNotification = { message: { target: mockConnectUrl, recording: m const mockFileName = 'mock.jfr' const mockFileUpload = new File([JSON.stringify(mockAnotherRecording)], mockFileName, {type: 'jfr'}); -const history = createMemoryHistory(); +const history = createMemoryHistory({initialEntries: ["/archives"]}); jest.mock('@app/RecordingMetadata/BulkEditLabels', () => { return { @@ -147,7 +147,6 @@ jest.spyOn(window, 'open').mockReturnValue(null); describe('', () => { beforeEach(() => { history.go(-history.length); - history.push('/archives'); }); afterEach(cleanup); diff --git a/src/test/Rules/CreateRule.test.tsx b/src/test/Rules/CreateRule.test.tsx index 11e46017d..4e1273dc5 100644 --- a/src/test/Rules/CreateRule.test.tsx +++ b/src/test/Rules/CreateRule.test.tsx @@ -95,7 +95,7 @@ const mockTargetFoundNotification = { } as NotificationMessage; -const history = createMemoryHistory(); +const history = createMemoryHistory({initialEntries: ["/rules"]}); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -113,7 +113,6 @@ jest.spyOn(defaultServices.targets, 'queryForTargets').mockReturnValue(of()); describe('', () => { beforeEach(() => { history.go(-history.length); - history.push('/rules'); }); afterEach(cleanup); diff --git a/src/test/Rules/Rules.test.tsx b/src/test/Rules/Rules.test.tsx index ef7b67f3d..9bd3676d6 100644 --- a/src/test/Rules/Rules.test.tsx +++ b/src/test/Rules/Rules.test.tsx @@ -68,7 +68,7 @@ mockFileUpload.text = jest.fn(() => new Promise( (resolve, _) => resolve(JSON.st const mockDeleteNotification = { message: {...mockRule} } as NotificationMessage; -const history = createMemoryHistory(); +const history = createMemoryHistory({initialEntries: ["/rules"]}); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -109,7 +109,6 @@ jest.spyOn(defaultServices.notificationChannel, 'messages') describe('', () => { beforeEach(() => { history.go(-history.length); - history.push('/rules'); }); afterEach(cleanup); @@ -139,7 +138,7 @@ describe('', () => { userEvent.click(screen.getByRole('button', { name: /Create/ })); - expect(history.entries.map((entry) => entry.pathname)).toStrictEqual(['/', '/rules', '/rules/create']); + expect(history.entries.map((entry) => entry.pathname)).toStrictEqual(['/rules', '/rules/create']); }); it('opens upload modal when upload icon is clicked', async () => { From 064846eaf3cd8619eca36ff92b68379104cbea01 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 8 Aug 2022 19:43:37 -0400 Subject: [PATCH 02/98] fix(test, recordings): add router for all rendered components --- .../Recordings/ActiveRecordingsTable.test.tsx | 72 ++++++++++++------- .../ArchivedRecordingsTable.test.tsx | 46 ++++++++---- 2 files changed, 80 insertions(+), 38 deletions(-) diff --git a/src/test/Recordings/ActiveRecordingsTable.test.tsx b/src/test/Recordings/ActiveRecordingsTable.test.tsx index 87495d036..1b3536240 100644 --- a/src/test/Recordings/ActiveRecordingsTable.test.tsx +++ b/src/test/Recordings/ActiveRecordingsTable.test.tsx @@ -36,6 +36,7 @@ * SOFTWARE. */ import * as React from 'react'; +import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import renderer, { act } from 'react-test-renderer'; import { render, screen, within } from '@testing-library/react'; @@ -86,17 +87,6 @@ jest.mock('react-router-dom', () => ({ useHistory: () => history, })); -jest.mock('@app/Recordings/RecordingFilters', () => { - return { - ...jest.requireActual('@app/Recordings/RecordingFilters'), - RecordingFilters: jest.fn(() => { - return
- RecordingFilters -
- }) - }; -}); - import { ActiveRecordingsTable } from '@app/Recordings/ActiveRecordingsTable'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import { DeleteActiveRecordings, DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; @@ -165,7 +155,9 @@ jest await act(async () => { tree = renderer.create( - + + + ); }); @@ -175,7 +167,9 @@ jest it('adds a recording after receiving a notification', () => { render( - + + + ); expect(screen.getByText('someRecording')).toBeInTheDocument(); @@ -185,7 +179,9 @@ jest it('updates the recording labels after receiving a notification', () => { render( - + + + ); expect(screen.getByText('someLabel: someUpdatedValue')).toBeInTheDocument(); @@ -195,7 +191,9 @@ jest it('stops a recording after receiving a notification', () => { render( - + + + ); expect(screen.getByText('STOPPED')).toBeInTheDocument(); @@ -205,7 +203,9 @@ jest it('removes a recording after receiving a notification', () => { render( - + + + ); expect(screen.queryByText('someRecording')).not.toBeInTheDocument(); @@ -214,7 +214,9 @@ jest it('displays the toolbar buttons', () => { render( - + + + ); @@ -228,7 +230,9 @@ jest it('routes to the Create Flight Recording form when Create is clicked', () => { render( - + + + ); @@ -240,7 +244,9 @@ jest it('archives the selected recording when Archive is clicked', () => { render( - + + + ); @@ -258,7 +264,9 @@ jest it('stops the selected recording when Stop is clicked', () => { render( - + + + ); @@ -276,7 +284,9 @@ jest it('opens the labels drawer when Edit Labels is clicked', () => { render( - + + + ); @@ -290,7 +300,9 @@ jest it('shows a popup when Delete is clicked and then deletes the recording after clicking confirmation Delete', () => { render( - + + + ); @@ -315,7 +327,9 @@ jest it('deletes the recording when Delete is clicked w/o popup warning', () => { render( - + + + ); @@ -334,7 +348,9 @@ jest it('downloads a recording when Download Recording is clicked', () => { render( - + + + ); @@ -350,7 +366,9 @@ jest it('displays the automated analysis report when View Report is clicked', () => { render( - + + + ); @@ -365,7 +383,9 @@ jest it('uploads a recording to Grafana when View in Grafana is clicked', () => { render( - + + + ); diff --git a/src/test/Recordings/ArchivedRecordingsTable.test.tsx b/src/test/Recordings/ArchivedRecordingsTable.test.tsx index e3c754e40..6a2121c53 100644 --- a/src/test/Recordings/ArchivedRecordingsTable.test.tsx +++ b/src/test/Recordings/ArchivedRecordingsTable.test.tsx @@ -156,7 +156,9 @@ describe('', () => { await act(async () => { tree = renderer.create( - + + + ); }); @@ -166,7 +168,9 @@ describe('', () => { it('adds a recording after receiving a notification', () => { render( - + + + ); expect(screen.getByText('someRecording')).toBeInTheDocument(); @@ -176,7 +180,9 @@ describe('', () => { it('updates the recording labels after receiving a notification', () => { render( - + + + ); expect(screen.getByText('someLabel: someUpdatedValue')).toBeInTheDocument(); @@ -186,7 +192,9 @@ describe('', () => { it('removes a recording after receiving a notification', () => { render( - + + + ); expect(screen.queryByText('someRecording')).not.toBeInTheDocument(); @@ -195,7 +203,9 @@ describe('', () => { it('displays the toolbar buttons', () => { render( - + + + ); @@ -206,7 +216,9 @@ describe('', () => { it('opens the labels drawer when Edit Labels is clicked', () => { render( - + + + ); @@ -220,7 +232,9 @@ describe('', () => { it('shows a popup when Delete is clicked and then deletes the recording after clicking confirmation Delete', () => { render( - + + + ); @@ -245,7 +259,9 @@ describe('', () => { it('deletes the recording when Delete is clicked w/o popup warning', () => { render( - + + + ); @@ -264,7 +280,9 @@ describe('', () => { it('downloads a recording when Download Recording is clicked', () => { render( - + + + ); @@ -280,7 +298,9 @@ describe('', () => { it('displays the automated analysis report when View Report is clicked', () => { render( - + + + ); @@ -295,7 +315,9 @@ describe('', () => { it('uploads a recording to Grafana when View in Grafana is clicked', () => { render( - + + + ); @@ -312,7 +334,7 @@ describe('', () => { render( - + ); From 48c4e4e40afd44586e1c9ba1157f63f17cb80295 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 8 Aug 2022 19:46:24 -0400 Subject: [PATCH 03/98] fix(test, activeRecording): add cleanup after each tests --- src/test/Recordings/ActiveRecordingsTable.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/Recordings/ActiveRecordingsTable.test.tsx b/src/test/Recordings/ActiveRecordingsTable.test.tsx index 1b3536240..a2811ec8d 100644 --- a/src/test/Recordings/ActiveRecordingsTable.test.tsx +++ b/src/test/Recordings/ActiveRecordingsTable.test.tsx @@ -39,7 +39,7 @@ import * as React from 'react'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import renderer, { act } from 'react-test-renderer'; -import { render, screen, within } from '@testing-library/react'; +import { cleanup, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { of } from 'rxjs'; @@ -150,6 +150,8 @@ jest history.go(-history.length); }); + afterEach(cleanup); + it('renders correctly', async () => { let tree; await act(async () => { From 22bcc1cabff361164a152f14d534eb99d313e09b Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 1 Sep 2022 14:48:16 -0400 Subject: [PATCH 04/98] fix(filters): filter dropdown should close after selecting --- src/app/Recordings/RecordingFilters.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index bd55dc548..31fbbac3c 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -81,9 +81,9 @@ export const RecordingFilters: React.FunctionComponent = const onCategorySelect = React.useCallback( (curr) => { + setIsCategoryDropdownOpen(false); setCurrentCategory(curr); - }, - [setCurrentCategory] + },[setCurrentCategory, setIsCategoryDropdownOpen] ); const onFilterToggle = React.useCallback(() => { @@ -212,13 +212,13 @@ export const RecordingFilters: React.FunctionComponent = } isOpen={isCategoryDropdownOpen} - dropdownItems={[ + dropdownItems={ Object.keys(props.filters).map((cat) => ( onCategorySelect(cat)}> {cat} - )), - ]} + )) + } > ); From 1d5d206624f5e6e05208a3ebc5347a0c827e3ed1 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 1 Sep 2022 17:14:56 -0400 Subject: [PATCH 05/98] chore(filters): move state filter to its own source file --- src/app/Recordings/LabelFilter.tsx | 7 +- src/app/Recordings/NameFilter.tsx | 7 +- src/app/Recordings/RecordingFilters.tsx | 31 ++------ src/app/Recordings/RecordingStateFilter.tsx | 80 +++++++++++++++++++++ 4 files changed, 89 insertions(+), 36 deletions(-) create mode 100644 src/app/Recordings/RecordingStateFilter.tsx diff --git a/src/app/Recordings/LabelFilter.tsx b/src/app/Recordings/LabelFilter.tsx index 34300ad3d..09c4699bc 100644 --- a/src/app/Recordings/LabelFilter.tsx +++ b/src/app/Recordings/LabelFilter.tsx @@ -38,19 +38,14 @@ import React from 'react'; import { Label, Select, SelectOption, SelectVariant } from '@patternfly/react-core'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { ServiceContext } from '@app/Shared/Services/Services'; import { ArchivedRecording } from '@app/Shared/Services/Api.service'; export interface LabelFilterProps { recordings: ArchivedRecording[]; - onSubmit: (inputName) => void; + onSubmit: (inputLabel: string) => void; } export const LabelFilter: React.FunctionComponent = (props) => { - const addSubscription = useSubscriptions(); - const context = React.useContext(ServiceContext); - const [isOpen, setIsOpen] = React.useState(false); const [selected, setSelected] = React.useState(''); const [labels, setLabels] = React.useState([] as string[]); diff --git a/src/app/Recordings/NameFilter.tsx b/src/app/Recordings/NameFilter.tsx index 708bc0730..012ce768f 100644 --- a/src/app/Recordings/NameFilter.tsx +++ b/src/app/Recordings/NameFilter.tsx @@ -38,19 +38,14 @@ import React from 'react'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { ServiceContext } from '@app/Shared/Services/Services'; import { ArchivedRecording } from '@app/Shared/Services/Api.service'; export interface NameFilterProps { recordings: ArchivedRecording[]; - onSubmit: (inputName) => void; + onSubmit: (inputName: string) => void; } export const NameFilter: React.FunctionComponent = (props) => { - const addSubscription = useSubscriptions(); - const context = React.useContext(ServiceContext); - const [isOpen, setIsOpen] = React.useState(false); const [selected, setSelected] = React.useState(''); const [names, setNames] = React.useState([] as string[]); diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index 31fbbac3c..7962f2f21 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -36,7 +36,7 @@ * SOFTWARE. */ -import { ArchivedRecording, RecordingState } from '@app/Shared/Services/Api.service'; +import { ArchivedRecording } from '@app/Shared/Services/Api.service'; import { Checkbox, Dropdown, @@ -46,9 +46,6 @@ import { Flex, FlexItem, InputGroup, - Select, - SelectOption, - SelectVariant, TextInput, ToolbarFilter, ToolbarGroup, @@ -61,6 +58,7 @@ import { RecordingFiltersCategories } from './ActiveRecordingsTable'; import { DateTimePicker } from './DateTimePicker'; import { LabelFilter } from './LabelFilter'; import { NameFilter } from './NameFilter'; +import { RecordingStateFilter } from './RecordingStateFilter'; export interface RecordingFiltersProps { recordings: ArchivedRecording[]; @@ -71,7 +69,6 @@ export interface RecordingFiltersProps { export const RecordingFilters: React.FunctionComponent = (props) => { const [currentCategory, setCurrentCategory] = React.useState('Name'); const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = React.useState(false); - const [isFilterDropdownOpen, setIsFilterDropdownOpen] = React.useState(false); const [continuous, setContinuous] = React.useState(false); const [duration, setDuration] = React.useState(30); @@ -81,15 +78,11 @@ export const RecordingFilters: React.FunctionComponent = const onCategorySelect = React.useCallback( (curr) => { - setIsCategoryDropdownOpen(false); setCurrentCategory(curr); + setIsCategoryDropdownOpen(false); },[setCurrentCategory, setIsCategoryDropdownOpen] ); - const onFilterToggle = React.useCallback(() => { - setIsFilterDropdownOpen((opened) => !opened); - }, [setIsFilterDropdownOpen]); - const onDelete = React.useCallback( (type = '', id = '') => { if (type) { @@ -173,7 +166,7 @@ export const RecordingFilters: React.FunctionComponent = ); const onRecordingStateSelect = React.useCallback( - (e, searchState) => { + (searchState) => { props.setFilters((old) => { if (!old.State) return old; @@ -232,19 +225,9 @@ export const RecordingFilters: React.FunctionComponent = , - , + + + , , diff --git a/src/app/Recordings/RecordingStateFilter.tsx b/src/app/Recordings/RecordingStateFilter.tsx new file mode 100644 index 000000000..115ddb692 --- /dev/null +++ b/src/app/Recordings/RecordingStateFilter.tsx @@ -0,0 +1,80 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + +import React from 'react'; +import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; +import { RecordingState } from '@app/Shared/Services/Api.service'; + +export interface RecordingStateFilterProps { + states: RecordingState[] | undefined; + onSubmit: (state: any) => void; +} + +export const RecordingStateFilter: React.FunctionComponent = (props) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState(new Set(props.states)); + + const onSelect = React.useCallback( + (event, selection, isPlaceholder) => { + if (isPlaceholder) { + setSelected(new Set()); + setIsOpen(false); + } else { + setSelected((selected) => selected.add(selection)); + props.onSubmit(selection); + } + }, [setSelected, setIsOpen, props.onSubmit]); + + return ( + + ); +}; From fdf6b81eace0ba6e3e270c6189c43d24fc321190 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 1 Sep 2022 19:36:05 -0400 Subject: [PATCH 06/98] fix(filters): current filter category should remain --- src/app/Recordings/ActiveRecordingsTable.tsx | 22 +++- .../Recordings/ArchivedRecordingsTable.tsx | 18 ++- src/app/Recordings/RecordingFilters.tsx | 114 +++++------------- 3 files changed, 64 insertions(+), 90 deletions(-) diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 11da068e8..1c238ce36 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -85,6 +85,7 @@ export const ActiveRecordingsTable: React.FunctionComponent }, [recordings, checkedIndices]); + const updateFilters = React.useCallback((filterValue: string, filterKey: string, deleted = false) => { + setCurrentFilterCategory(filterKey); + setFilters((old) => { + if (!old[filterKey]) return old; + + const oldFilterValues = old[filterKey] as any[]; + const filterValues = deleted? oldFilterValues.filter((val) => val !== filterValue): + Array.from(new Set([...oldFilterValues, filterValue])); + const newFilters = {...old}; + newFilters[filterKey] = filterValues; + return newFilters; + }); + + }, [setCurrentFilterCategory, setFilters]); + return ( - - { buttons } - { deleteActiveWarningModal } + + { buttons } + { deleteActiveWarningModal } ); diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index aa984bc1e..0de705a97 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -75,6 +75,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent }, [recordings, checkedIndices]); + const updateFilters = React.useCallback((filterValue: string, filterKey: string, deleted = false) => { + setCurrentFilterCategory(filterKey); + setFilters((old) => { + if (!old[filterKey]) return old; + + const oldFilterValues = old[filterKey] as any[]; + const filterValues = deleted? oldFilterValues.filter((val) => val !== filterValue): + Array.from(new Set([...oldFilterValues, filterValue])); + const newFilters = {...old}; + newFilters[filterKey] = filterValues; + return newFilters; + }); + + }, [setCurrentFilterCategory, setFilters]); + return ( - + diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index 7962f2f21..64a461c78 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -53,7 +53,7 @@ import { ToolbarToggleGroup, } from '@patternfly/react-core'; import { FilterIcon } from '@patternfly/react-icons'; -import React, { Dispatch, SetStateAction } from 'react'; +import React from 'react'; import { RecordingFiltersCategories } from './ActiveRecordingsTable'; import { DateTimePicker } from './DateTimePicker'; import { LabelFilter } from './LabelFilter'; @@ -61,13 +61,15 @@ import { NameFilter } from './NameFilter'; import { RecordingStateFilter } from './RecordingStateFilter'; export interface RecordingFiltersProps { + category: string, recordings: ArchivedRecording[]; filters: RecordingFiltersCategories; - setFilters: Dispatch>; + updateFilters: (filterValue: any, filterKey: string, deleted?: boolean) => void; + clearFilters: () => void; } export const RecordingFilters: React.FunctionComponent = (props) => { - const [currentCategory, setCurrentCategory] = React.useState('Name'); + const [currentCategory, setCurrentCategory] = React.useState(props.category); const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = React.useState(false); const [continuous, setContinuous] = React.useState(false); const [duration, setDuration] = React.useState(30); @@ -84,114 +86,54 @@ export const RecordingFilters: React.FunctionComponent = ); const onDelete = React.useCallback( - (type = '', id = '') => { - if (type) { - props.setFilters((old) => { - return { ...old, [type]: old[type].filter((val) => val !== id) }; - }); - } else { - props.setFilters(() => { - return { - Name: [], - Labels: [], - State: [], - StartedBeforeDate: [], - StartedAfterDate: [], - DurationSeconds: [], - }; - }); - } + (category, value) => { + props.updateFilters(value, category, true); }, - [props.setFilters] + [props.updateFilters] ); const onNameInput = React.useCallback( - (inputName) => { - props.setFilters((old) => { - const names = new Set(old.Name); - names.add(inputName); - return { ...old, Name: Array.from(names) }; - }); - }, - [props.setFilters] + (inputName) => props.updateFilters(inputName, currentCategory), + [props.updateFilters, currentCategory] ); const onLabelInput = React.useCallback( - (inputLabel) => { - props.setFilters((old) => { - const labels = new Set(old.Labels); - labels.add(inputLabel); - return { ...old, Labels: Array.from(labels) }; - }); - }, - [props.setFilters] + (inputLabel) => props.updateFilters(inputLabel, currentCategory), + [props.updateFilters, currentCategory] ); - const onStartedBeforeInput = React.useCallback((searchDate) => { - props.setFilters((old) => { - if (!old.StartedBeforeDate) return old; - - const dates = new Set(old.StartedBeforeDate); - dates.add(searchDate); - return { ...old, StartedBeforeDate: Array.from(dates) }; - }); - }, [props.setFilters]); - - const onStartedAfterInput = React.useCallback((searchDate) => { - props.setFilters((old) => { - if (!old.StartedAfterDate) return old; + const onStartedBeforeInput = React.useCallback( + (searchDate) => props.updateFilters(searchDate, currentCategory), + [props.updateFilters, currentCategory] + ); - const dates = new Set(old.StartedAfterDate); - dates.add(searchDate); - return { ...old, StartedAfterDate: Array.from(dates) }; - }); - }, [props.setFilters]); + const onStartedAfterInput = React.useCallback( + (searchDate) => props.updateFilters(searchDate, currentCategory), + [props.updateFilters, currentCategory] + ); const onDurationInput = React.useCallback( (e) => { if (e.key && e.key !== 'Enter') { return; } - - props.setFilters((old) => { - if (!old.DurationSeconds) return old; - const dur = `${duration.toString()} s`; - - const durations = new Set(old.DurationSeconds); - durations.add(dur); - return { ...old, DurationSeconds: Array.from(durations) }; - }); + props.updateFilters(`${duration.toString()} s`, currentCategory); }, - [duration, props.setFilters] + [duration, props.updateFilters, currentCategory] ); const onRecordingStateSelect = React.useCallback( - (searchState) => { - props.setFilters((old) => { - if (!old.State) return old; - - const states = new Set(old.State); - states.add(searchState); - return { ...old, State: Array.from(states) }; - }); - }, - [props.setFilters] + (searchState) => props.updateFilters(searchState, currentCategory), + [props.updateFilters, currentCategory] ); const onContinuousDurationSelect = React.useCallback( (cont) => { setContinuous(cont); - props.setFilters((old) => { - if (!old.DurationSeconds) return old; - return { - ...old, - DurationSeconds: cont - ? [...old.DurationSeconds, 'continuous'] - : old.DurationSeconds.filter((v) => v != 'continuous'), - }; - }); + let contValue = cont? 'continuous': null; + props.updateFilters(contValue, currentCategory, !contValue); }, - [setContinuous, props.setFilters] + [setContinuous, props.updateFilters, currentCategory] ); const categoryDropdown = React.useMemo(() => { @@ -271,7 +213,7 @@ export const RecordingFilters: React.FunctionComponent = chips={props.filters[filterKey]} deleteChip={onDelete} categoryName={filterKey} - showToolbarItem={currentCategory === filterKey} + showToolbarItem={filterKey === currentCategory} > {filterDropdownItems[i]} From b249eff486d2d8d2dbc35546c2ba6f2006f8ae85 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 1 Sep 2022 20:17:10 -0400 Subject: [PATCH 07/98] fix(filters): users should able to bulk delete filters in the same category --- src/app/Recordings/ActiveRecordingsTable.tsx | 19 ++++++--- .../Recordings/ArchivedRecordingsTable.tsx | 21 +++++++--- src/app/Recordings/RecordingFilters.tsx | 39 +++++++++++++------ 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 1c238ce36..6909a0d57 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -51,7 +51,7 @@ import { concatMap, filter, first } from 'rxjs/operators'; import { LabelCell } from '../RecordingMetadata/LabelCell'; import { RecordingActions } from './RecordingActions'; import { RecordingLabelsPanel } from './RecordingLabelsPanel'; -import { filterRecordings, RecordingFilters } from './RecordingFilters'; +import { FilterDeleteOptions, filterRecordings, RecordingFilters } from './RecordingFilters'; import { RecordingsTable } from './RecordingsTable'; import { ReportFrame } from './ReportFrame'; import { DeleteWarningModal } from '../Modal/DeleteWarningModal'; @@ -529,14 +529,23 @@ export const ActiveRecordingsTable: React.FunctionComponent }, [recordings, checkedIndices]); - const updateFilters = React.useCallback((filterValue: string, filterKey: string, deleted = false) => { + const updateFilters = React.useCallback(({filterValue, filterKey, deleted = false, deleteOptions}) => { setCurrentFilterCategory(filterKey); setFilters((old) => { if (!old[filterKey]) return old; const oldFilterValues = old[filterKey] as any[]; - const filterValues = deleted? oldFilterValues.filter((val) => val !== filterValue): - Array.from(new Set([...oldFilterValues, filterValue])); + let filterValues: any[]; + if (deleted) { + if (deleteOptions && (deleteOptions as FilterDeleteOptions).all) { + filterValues = []; + } else { + filterValues = oldFilterValues.filter((val) => val !== filterValue); + } + } else { + filterValues = Array.from(new Set([...oldFilterValues, filterValue])); + } + const newFilters = {...old}; newFilters[filterKey] = filterValues; return newFilters; @@ -547,7 +556,7 @@ export const ActiveRecordingsTable: React.FunctionComponent - + { buttons } { deleteActiveWarningModal } diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index 0de705a97..be652658a 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -41,7 +41,7 @@ import { ServiceContext } from '@app/Shared/Services/Services'; import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { Button, Checkbox, Drawer, DrawerContent, DrawerContentBody, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from '@patternfly/react-core'; -import { Tbody, Th, Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; +import { Tbody, Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; import { PlusIcon } from '@patternfly/react-icons'; import { RecordingActions } from './RecordingActions'; import { RecordingsTable } from './RecordingsTable'; @@ -55,7 +55,7 @@ import { RecordingLabelsPanel } from './RecordingLabelsPanel'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; import { RecordingFiltersCategories } from './ActiveRecordingsTable'; -import { filterRecordings, RecordingFilters } from './RecordingFilters'; +import { FilterDeleteOptions, filterRecordings, RecordingFilters } from './RecordingFilters'; import { ArchiveUploadModal } from '@app/Archives/ArchiveUploadModal'; export interface ArchivedRecordingsTableProps { @@ -384,14 +384,23 @@ export const ArchivedRecordingsTable: React.FunctionComponent }, [recordings, checkedIndices]); - const updateFilters = React.useCallback((filterValue: string, filterKey: string, deleted = false) => { + const updateFilters = React.useCallback(({filterValue, filterKey, deleted = false, deleteOptions}) => { setCurrentFilterCategory(filterKey); setFilters((old) => { if (!old[filterKey]) return old; const oldFilterValues = old[filterKey] as any[]; - const filterValues = deleted? oldFilterValues.filter((val) => val !== filterValue): - Array.from(new Set([...oldFilterValues, filterValue])); + let filterValues: any[]; + if (deleted) { + if (deleteOptions && (deleteOptions as FilterDeleteOptions).all) { + filterValues = []; + } else { + filterValues = oldFilterValues.filter((val) => val !== filterValue); + } + } else { + filterValues = Array.from(new Set([...oldFilterValues, filterValue])); + } + const newFilters = {...old}; newFilters[filterKey] = filterValues; return newFilters; @@ -402,7 +411,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent - + diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index 64a461c78..0ef7efcc0 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -64,8 +64,18 @@ export interface RecordingFiltersProps { category: string, recordings: ArchivedRecording[]; filters: RecordingFiltersCategories; - updateFilters: (filterValue: any, filterKey: string, deleted?: boolean) => void; - clearFilters: () => void; + updateFilters: (updatefilterParams: UpdateFilterOptions) => void; +} + +export interface UpdateFilterOptions { + filterKey: string; + filterValue?: any; + deleted?: boolean; + deleteOptions?: FilterDeleteOptions +} + +export interface FilterDeleteOptions { + all: boolean } export const RecordingFilters: React.FunctionComponent = (props) => { @@ -87,28 +97,35 @@ export const RecordingFilters: React.FunctionComponent = const onDelete = React.useCallback( (category, value) => { - props.updateFilters(value, category, true); + props.updateFilters({ filterKey: category, filterValue: value, deleted: true}); + }, + [props.updateFilters] + ); + + const onDeleteGroup = React.useCallback( + (category) => { + props.updateFilters({ filterKey: category, deleted: true, deleteOptions: { all: true }}); }, [props.updateFilters] ); const onNameInput = React.useCallback( - (inputName) => props.updateFilters(inputName, currentCategory), + (inputName) => props.updateFilters({ filterKey: currentCategory, filterValue: inputName }), [props.updateFilters, currentCategory] ); const onLabelInput = React.useCallback( - (inputLabel) => props.updateFilters(inputLabel, currentCategory), + (inputLabel) => props.updateFilters({ filterKey: currentCategory, filterValue: inputLabel }), [props.updateFilters, currentCategory] ); const onStartedBeforeInput = React.useCallback( - (searchDate) => props.updateFilters(searchDate, currentCategory), + (searchDate) => props.updateFilters({ filterKey: currentCategory, filterValue: searchDate}), [props.updateFilters, currentCategory] ); const onStartedAfterInput = React.useCallback( - (searchDate) => props.updateFilters(searchDate, currentCategory), + (searchDate) => props.updateFilters({ filterKey: currentCategory, filterValue: searchDate}), [props.updateFilters, currentCategory] ); @@ -117,21 +134,20 @@ export const RecordingFilters: React.FunctionComponent = if (e.key && e.key !== 'Enter') { return; } - props.updateFilters(`${duration.toString()} s`, currentCategory); + props.updateFilters({ filterKey: currentCategory, filterValue: `${duration.toString()} s` }); }, [duration, props.updateFilters, currentCategory] ); const onRecordingStateSelect = React.useCallback( - (searchState) => props.updateFilters(searchState, currentCategory), + (searchState) => props.updateFilters({ filterKey: currentCategory, filterValue: searchState }), [props.updateFilters, currentCategory] ); const onContinuousDurationSelect = React.useCallback( (cont) => { setContinuous(cont); - let contValue = cont? 'continuous': null; - props.updateFilters(contValue, currentCategory, !contValue); + props.updateFilters({ filterKey: currentCategory, filterValue: 'continuous', deleted: !cont }); }, [setContinuous, props.updateFilters, currentCategory] ); @@ -212,6 +228,7 @@ export const RecordingFilters: React.FunctionComponent = key={filterKey} chips={props.filters[filterKey]} deleteChip={onDelete} + deleteChipGroup={onDeleteGroup} categoryName={filterKey} showToolbarItem={filterKey === currentCategory} > From f81dd98d38e21e10bf8d9e312837c6669a43470a Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 1 Sep 2022 20:48:40 -0400 Subject: [PATCH 08/98] !tmp(tests): add mocks until new filter features are implemented --- src/test/Recordings/ActiveRecordingsTable.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/test/Recordings/ActiveRecordingsTable.test.tsx b/src/test/Recordings/ActiveRecordingsTable.test.tsx index a2811ec8d..d923fab5b 100644 --- a/src/test/Recordings/ActiveRecordingsTable.test.tsx +++ b/src/test/Recordings/ActiveRecordingsTable.test.tsx @@ -87,6 +87,17 @@ jest.mock('react-router-dom', () => ({ useHistory: () => history, })); +jest.mock('@app/Recordings/RecordingFilters', () => { + return { + ...jest.requireActual('@app/Recordings/RecordingFilters'), + RecordingFilters: jest.fn(() => { + return
+ RecordingFilters +
+ }) + }; +}); + import { ActiveRecordingsTable } from '@app/Recordings/ActiveRecordingsTable'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import { DeleteActiveRecordings, DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; From 7cdbee291722b31b2170b4e487eb7eb2e861b7a3 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 5 Sep 2022 18:05:09 -0400 Subject: [PATCH 09/98] chore(filters): move durationFilter to a separate source file --- src/app/Recordings/ActiveRecordingsTable.tsx | 10 +-- src/app/Recordings/DurationFilter.tsx | 81 ++++++++++++++++++++ src/app/Recordings/RecordingFilters.tsx | 55 +++---------- 3 files changed, 97 insertions(+), 49 deletions(-) create mode 100644 src/app/Recordings/DurationFilter.tsx diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 6909a0d57..7057f8098 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -535,19 +535,19 @@ export const ActiveRecordingsTable: React.FunctionComponent val !== filterValue); + newfilterValues = oldFilterValues.filter((val) => val !== filterValue); } } else { - filterValues = Array.from(new Set([...oldFilterValues, filterValue])); + newfilterValues = Array.from(new Set([...oldFilterValues, filterValue])); } const newFilters = {...old}; - newFilters[filterKey] = filterValues; + newFilters[filterKey] = newfilterValues; return newFilters; }); diff --git a/src/app/Recordings/DurationFilter.tsx b/src/app/Recordings/DurationFilter.tsx new file mode 100644 index 000000000..030de3c83 --- /dev/null +++ b/src/app/Recordings/DurationFilter.tsx @@ -0,0 +1,81 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react'; +import { Checkbox, Flex, FlexItem, TextInput } from '@patternfly/react-core'; + +export interface DurationFilterProps { + durations: string[] | undefined; + onDurationInput: (e: any) => void; + onContinuousDurationSelect: (checked: boolean) => void; +} + +export const DurationFilter: React.FunctionComponent = (props) => { + const [duration, setDuration] = React.useState(30); + const isContinuous = React.useMemo(() => props.durations && props.durations.includes("continuous"), + [props.durations]); + + return ( + + + setDuration(Number(e))} + min="0" + onKeyDown={(e) => { + if (e.key && e.key !== 'Enter') { + return; + } + props.onDurationInput(duration); + }} + /> + + + + + + ); +}; diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index 0ef7efcc0..e52e0bca3 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -56,6 +56,7 @@ import { FilterIcon } from '@patternfly/react-icons'; import React from 'react'; import { RecordingFiltersCategories } from './ActiveRecordingsTable'; import { DateTimePicker } from './DateTimePicker'; +import { DurationFilter } from './DurationFilter'; import { LabelFilter } from './LabelFilter'; import { NameFilter } from './NameFilter'; import { RecordingStateFilter } from './RecordingStateFilter'; @@ -64,7 +65,7 @@ export interface RecordingFiltersProps { category: string, recordings: ArchivedRecording[]; filters: RecordingFiltersCategories; - updateFilters: (updatefilterParams: UpdateFilterOptions) => void; + updateFilters: (updateFilterOptions: UpdateFilterOptions) => void; } export interface UpdateFilterOptions { @@ -81,8 +82,6 @@ export interface FilterDeleteOptions { export const RecordingFilters: React.FunctionComponent = (props) => { const [currentCategory, setCurrentCategory] = React.useState(props.category); const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = React.useState(false); - const [continuous, setContinuous] = React.useState(false); - const [duration, setDuration] = React.useState(30); const onCategoryToggle = React.useCallback(() => { setIsCategoryDropdownOpen((opened) => !opened); @@ -96,16 +95,12 @@ export const RecordingFilters: React.FunctionComponent = ); const onDelete = React.useCallback( - (category, value) => { - props.updateFilters({ filterKey: category, filterValue: value, deleted: true}); - }, + (category, value) => props.updateFilters({ filterKey: category, filterValue: value, deleted: true}), [props.updateFilters] ); const onDeleteGroup = React.useCallback( - (category) => { - props.updateFilters({ filterKey: category, deleted: true, deleteOptions: { all: true }}); - }, + (category) => props.updateFilters({ filterKey: category, deleted: true, deleteOptions: { all: true }}), [props.updateFilters] ); @@ -130,13 +125,8 @@ export const RecordingFilters: React.FunctionComponent = ); const onDurationInput = React.useCallback( - (e) => { - if (e.key && e.key !== 'Enter') { - return; - } - props.updateFilters({ filterKey: currentCategory, filterValue: `${duration.toString()} s` }); - }, - [duration, props.updateFilters, currentCategory] + (duration) => props.updateFilters({ filterKey: currentCategory, filterValue: `${duration.toString()} s` }), + [props.updateFilters, currentCategory] ); const onRecordingStateSelect = React.useCallback( @@ -145,11 +135,8 @@ export const RecordingFilters: React.FunctionComponent = ); const onContinuousDurationSelect = React.useCallback( - (cont) => { - setContinuous(cont); - props.updateFilters({ filterKey: currentCategory, filterValue: 'continuous', deleted: !cont }); - }, - [setContinuous, props.updateFilters, currentCategory] + (cont) => props.updateFilters({ filterKey: currentCategory, filterValue: 'continuous', deleted: !cont }), + [props.updateFilters, currentCategory] ); const categoryDropdown = React.useMemo(() => { @@ -190,30 +177,10 @@ export const RecordingFilters: React.FunctionComponent = , - - , + + , - - - setDuration(Number(e))} - min="0" - onKeyDown={onDurationInput} - /> - - - onContinuousDurationSelect(checked)} - /> - - + , ], [Object.keys(props.filters)] From 91ee0d53179bb16ea24f9e5f55da16fa7aa7c9bb Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 5 Sep 2022 18:08:18 -0400 Subject: [PATCH 10/98] chore(filters): move filters to subdirectory of Recordings --- src/app/Recordings/{ => Filters}/DateTimePicker.tsx | 2 +- src/app/Recordings/{ => Filters}/DurationFilter.tsx | 0 src/app/Recordings/{ => Filters}/LabelFilter.tsx | 0 src/app/Recordings/{ => Filters}/NameFilter.tsx | 0 .../Recordings/{ => Filters}/RecordingStateFilter.tsx | 0 src/app/Recordings/RecordingFilters.tsx | 10 +++++----- 6 files changed, 6 insertions(+), 6 deletions(-) rename src/app/Recordings/{ => Filters}/DateTimePicker.tsx (99%) rename src/app/Recordings/{ => Filters}/DurationFilter.tsx (100%) rename src/app/Recordings/{ => Filters}/LabelFilter.tsx (100%) rename src/app/Recordings/{ => Filters}/NameFilter.tsx (100%) rename src/app/Recordings/{ => Filters}/RecordingStateFilter.tsx (100%) diff --git a/src/app/Recordings/DateTimePicker.tsx b/src/app/Recordings/Filters/DateTimePicker.tsx similarity index 99% rename from src/app/Recordings/DateTimePicker.tsx rename to src/app/Recordings/Filters/DateTimePicker.tsx index 37f911a19..f862134e2 100644 --- a/src/app/Recordings/DateTimePicker.tsx +++ b/src/app/Recordings/Filters/DateTimePicker.tsx @@ -53,7 +53,7 @@ import { SearchIcon } from '@patternfly/react-icons'; import React from 'react'; export interface DateTimePickerProps { - onSubmit: (startDate) => void; + onSubmit: (startDate: any) => void; } export const DateTimePicker: React.FunctionComponent = (props) => { diff --git a/src/app/Recordings/DurationFilter.tsx b/src/app/Recordings/Filters/DurationFilter.tsx similarity index 100% rename from src/app/Recordings/DurationFilter.tsx rename to src/app/Recordings/Filters/DurationFilter.tsx diff --git a/src/app/Recordings/LabelFilter.tsx b/src/app/Recordings/Filters/LabelFilter.tsx similarity index 100% rename from src/app/Recordings/LabelFilter.tsx rename to src/app/Recordings/Filters/LabelFilter.tsx diff --git a/src/app/Recordings/NameFilter.tsx b/src/app/Recordings/Filters/NameFilter.tsx similarity index 100% rename from src/app/Recordings/NameFilter.tsx rename to src/app/Recordings/Filters/NameFilter.tsx diff --git a/src/app/Recordings/RecordingStateFilter.tsx b/src/app/Recordings/Filters/RecordingStateFilter.tsx similarity index 100% rename from src/app/Recordings/RecordingStateFilter.tsx rename to src/app/Recordings/Filters/RecordingStateFilter.tsx diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index e52e0bca3..95b469d85 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -55,11 +55,11 @@ import { import { FilterIcon } from '@patternfly/react-icons'; import React from 'react'; import { RecordingFiltersCategories } from './ActiveRecordingsTable'; -import { DateTimePicker } from './DateTimePicker'; -import { DurationFilter } from './DurationFilter'; -import { LabelFilter } from './LabelFilter'; -import { NameFilter } from './NameFilter'; -import { RecordingStateFilter } from './RecordingStateFilter'; +import { DateTimePicker } from './Filters/DateTimePicker'; +import { DurationFilter } from './Filters/DurationFilter'; +import { LabelFilter } from './Filters/LabelFilter'; +import { NameFilter } from './Filters/NameFilter'; +import { RecordingStateFilter } from './Filters/RecordingStateFilter'; export interface RecordingFiltersProps { category: string, From f2816ae422e2c1407978c3f50204b86027d770b8 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 5 Sep 2022 18:38:16 -0400 Subject: [PATCH 11/98] chore(filters): renaming variables --- src/app/Recordings/ArchivedRecordingsTable.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index be652658a..15e8805f4 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -390,19 +390,19 @@ export const ArchivedRecordingsTable: React.FunctionComponent val !== filterValue); + newfilterValues = oldFilterValues.filter((val) => val !== filterValue); } } else { - filterValues = Array.from(new Set([...oldFilterValues, filterValue])); + newfilterValues = Array.from(new Set([...oldFilterValues, filterValue])); } const newFilters = {...old}; - newFilters[filterKey] = filterValues; + newFilters[filterKey] = newfilterValues; return newFilters; }); From 8efb133b94391c37be8c938b1b38973513969636 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 5 Sep 2022 19:15:20 -0400 Subject: [PATCH 12/98] chore(filters): move filter category interfaces to RecordingFilters file --- src/app/Recordings/ActiveRecordingsTable.tsx | 11 +---------- src/app/Recordings/ArchivedRecordingsTable.tsx | 2 +- src/app/Recordings/RecordingFilters.tsx | 15 ++++++++++----- src/app/Recordings/RecordingsTable.tsx | 5 ++++- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 7057f8098..aff393fba 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -51,7 +51,7 @@ import { concatMap, filter, first } from 'rxjs/operators'; import { LabelCell } from '../RecordingMetadata/LabelCell'; import { RecordingActions } from './RecordingActions'; import { RecordingLabelsPanel } from './RecordingLabelsPanel'; -import { FilterDeleteOptions, filterRecordings, RecordingFilters } from './RecordingFilters'; +import { FilterDeleteOptions, filterRecordings, RecordingFilters, RecordingFiltersCategories } from './RecordingFilters'; import { RecordingsTable } from './RecordingsTable'; import { ReportFrame } from './ReportFrame'; import { DeleteWarningModal } from '../Modal/DeleteWarningModal'; @@ -64,15 +64,6 @@ export interface ActiveRecordingsTableProps { archiveEnabled: boolean; } -export interface RecordingFiltersCategories { - Name: string[], - Labels: string[], - State?: RecordingState[], - StartedBeforeDate?: string[], - StartedAfterDate?: string[], - DurationSeconds?: string[], -} - export const ActiveRecordingsTable: React.FunctionComponent = (props) => { const context = React.useContext(ServiceContext); const routerHistory = useHistory(); diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index 15e8805f4..8965a6a82 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -54,7 +54,7 @@ import { LabelCell } from '../RecordingMetadata/LabelCell'; import { RecordingLabelsPanel } from './RecordingLabelsPanel'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; -import { RecordingFiltersCategories } from './ActiveRecordingsTable'; +import { RecordingFiltersCategories } from './RecordingFilters'; import { FilterDeleteOptions, filterRecordings, RecordingFilters } from './RecordingFilters'; import { ArchiveUploadModal } from '@app/Archives/ArchiveUploadModal'; diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index 95b469d85..fc674b269 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -38,15 +38,11 @@ import { ArchivedRecording } from '@app/Shared/Services/Api.service'; import { - Checkbox, Dropdown, DropdownItem, DropdownPosition, DropdownToggle, - Flex, - FlexItem, InputGroup, - TextInput, ToolbarFilter, ToolbarGroup, ToolbarItem, @@ -54,12 +50,21 @@ import { } from '@patternfly/react-core'; import { FilterIcon } from '@patternfly/react-icons'; import React from 'react'; -import { RecordingFiltersCategories } from './ActiveRecordingsTable'; import { DateTimePicker } from './Filters/DateTimePicker'; import { DurationFilter } from './Filters/DurationFilter'; import { LabelFilter } from './Filters/LabelFilter'; import { NameFilter } from './Filters/NameFilter'; import { RecordingStateFilter } from './Filters/RecordingStateFilter'; +import { RecordingState } from '@app/Shared/Services/Api.service'; + +export interface RecordingFiltersCategories { + Name: string[], + Labels: string[], + State?: RecordingState[], + StartedBeforeDate?: string[], + StartedAfterDate?: string[], + DurationSeconds?: string[], +} export interface RecordingFiltersProps { category: string, diff --git a/src/app/Recordings/RecordingsTable.tsx b/src/app/Recordings/RecordingsTable.tsx index 3a14fa4cd..5a6ba00ab 100644 --- a/src/app/Recordings/RecordingsTable.tsx +++ b/src/app/Recordings/RecordingsTable.tsx @@ -91,7 +91,10 @@ export const RecordingsTable: React.FunctionComponent = (p ); } else { view = (<> - + Date: Mon, 5 Sep 2022 21:13:43 -0400 Subject: [PATCH 13/98] fix(filters): filtered labels should be highlighted --- src/app/RecordingMetadata/LabelCell.tsx | 13 ++++++++++--- src/app/Recordings/ActiveRecordingsTable.tsx | 3 ++- src/app/Recordings/ArchivedRecordingsTable.tsx | 3 ++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/app/RecordingMetadata/LabelCell.tsx b/src/app/RecordingMetadata/LabelCell.tsx index 1292dfbbf..f976988c0 100644 --- a/src/app/RecordingMetadata/LabelCell.tsx +++ b/src/app/RecordingMetadata/LabelCell.tsx @@ -42,15 +42,22 @@ import { RecordingLabel } from './RecordingLabel'; export interface LabelCellProps { labels: RecordingLabel[]; + labelFilters?: string[]; } export const LabelCell: React.FunctionComponent = (props) => { // TODO make labels clickable to select multiple recordings with the same label return ( <> - {!!props.labels && props.labels.length ? ( - props.labels.map((l) => ) - ) : ( + {!!props.labels && props.labels.length? ( + props.labels.map((l) => + + )) : ( - )} diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index aff393fba..61941d227 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -392,6 +392,7 @@ export const ActiveRecordingsTable: React.FunctionComponent @@ -556,7 +557,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { - return filteredRecordings.map((r, idx) => ) + return filteredRecordings.map((r, idx) => ) }, [filteredRecordings, expandedRows, checkedIndices]); const LabelsPanel = React.useMemo(() => ( diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index 8965a6a82..62d790e10 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -325,6 +325,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent @@ -436,7 +437,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - return filteredRecordings.map((r, idx) => ) + return filteredRecordings.map((r, idx) => ) }, [filteredRecordings, expandedRows, checkedIndices]); const handleModalClose = React.useCallback(() => { From d2d65394beaa7ab56a3a8d064b16e3fa36dae3e2 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 5 Sep 2022 23:02:41 -0400 Subject: [PATCH 14/98] fix(filters): recording labels are now clickable --- src/app/RecordingMetadata/LabelCell.tsx | 22 ++++++++- src/app/Recordings/ActiveRecordingsTable.tsx | 49 ++++++++++--------- .../Recordings/ArchivedRecordingsTable.tsx | 49 ++++++++++--------- 3 files changed, 70 insertions(+), 50 deletions(-) diff --git a/src/app/RecordingMetadata/LabelCell.tsx b/src/app/RecordingMetadata/LabelCell.tsx index f976988c0..2df91e7e7 100644 --- a/src/app/RecordingMetadata/LabelCell.tsx +++ b/src/app/RecordingMetadata/LabelCell.tsx @@ -36,6 +36,7 @@ * SOFTWARE. */ +import { UpdateFilterOptions } from '@app/Recordings/RecordingFilters'; import { Label, Text } from '@patternfly/react-core'; import React from 'react'; import { RecordingLabel } from './RecordingLabel'; @@ -43,15 +44,32 @@ import { RecordingLabel } from './RecordingLabel'; export interface LabelCellProps { labels: RecordingLabel[]; labelFilters?: string[]; + onSubmit?: (updateFilterOptions: UpdateFilterOptions) => void } export const LabelCell: React.FunctionComponent = (props) => { - // TODO make labels clickable to select multiple recordings with the same label + const labelStyle = React.useMemo(() => (props.onSubmit? { + cursor: "pointer", + }: {}), + [props.onSubmit]); + + const onLabelSelectToggle = React.useCallback( + (label) => { + if (props.onSubmit) { + const deleted = props.labelFilters && props.labelFilters.includes(label) + props.onSubmit({filterKey: "Labels", filterValue: label, deleted: deleted}) + } + }, + [props.onSubmit, props.labelFilters]); + return ( <> {!!props.labels && props.labels.length? ( props.labels.map((l) => -
); - }, [Object.keys(props.filters), isCategoryDropdownOpen, onCategoryToggle, onCategorySelect]); + }, [Object.keys(props.filters), isCategoryDropdownOpen, currentCategory, onCategoryToggle, onCategorySelect]); const filterDropdownItems = React.useMemo( () => [ From f72ef39bc70e514cbaa44db3c4e17de04e3949de Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 6 Sep 2022 01:55:29 -0400 Subject: [PATCH 17/98] fix(filters): better naming for props callback --- src/app/RecordingMetadata/LabelCell.tsx | 14 ++++++++------ src/app/Recordings/ActiveRecordingsTable.tsx | 2 +- src/app/Recordings/ArchivedRecordingsTable.tsx | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/app/RecordingMetadata/LabelCell.tsx b/src/app/RecordingMetadata/LabelCell.tsx index 2df91e7e7..e38d2c28b 100644 --- a/src/app/RecordingMetadata/LabelCell.tsx +++ b/src/app/RecordingMetadata/LabelCell.tsx @@ -43,24 +43,26 @@ import { RecordingLabel } from './RecordingLabel'; export interface LabelCellProps { labels: RecordingLabel[]; + // Must be specified along with updateFilters. labelFilters?: string[]; - onSubmit?: (updateFilterOptions: UpdateFilterOptions) => void + // If undefined, labels are not clickable (i.e. display only). + updateFilters?: (updateFilterOptions: UpdateFilterOptions) => void } export const LabelCell: React.FunctionComponent = (props) => { - const labelStyle = React.useMemo(() => (props.onSubmit? { + const labelStyle = React.useMemo(() => (props.updateFilters? { cursor: "pointer", }: {}), - [props.onSubmit]); + [props.updateFilters]); const onLabelSelectToggle = React.useCallback( (label) => { - if (props.onSubmit) { + if (props.updateFilters) { const deleted = props.labelFilters && props.labelFilters.includes(label) - props.onSubmit({filterKey: "Labels", filterValue: label, deleted: deleted}) + props.updateFilters({filterKey: "Labels", filterValue: label, deleted: deleted}) } }, - [props.onSubmit, props.labelFilters]); + [props.updateFilters, props.labelFilters]); return ( <> diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 94023aa34..03a045288 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -416,7 +416,7 @@ export const ActiveRecordingsTable: React.FunctionComponent diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index c30368338..9d17cbcb1 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -349,7 +349,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent From 8a1373d67b008b89979a8d7a0938ec34090304d8 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 6 Sep 2022 11:52:29 -0400 Subject: [PATCH 18/98] fix(filters): use a common func to get label display format --- src/app/RecordingMetadata/LabelCell.tsx | 6 +++--- src/app/Recordings/Filters/LabelFilter.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/RecordingMetadata/LabelCell.tsx b/src/app/RecordingMetadata/LabelCell.tsx index e38d2c28b..54e6b29f3 100644 --- a/src/app/RecordingMetadata/LabelCell.tsx +++ b/src/app/RecordingMetadata/LabelCell.tsx @@ -36,6 +36,7 @@ * SOFTWARE. */ +import { getLabelDisplay } from '@app/Recordings/Filters/LabelFilter'; import { UpdateFilterOptions } from '@app/Recordings/RecordingFilters'; import { Label, Text } from '@patternfly/react-core'; import React from 'react'; @@ -69,11 +70,10 @@ export const LabelCell: React.FunctionComponent = (props) => { {!!props.labels && props.labels.length? ( props.labels.map((l) => diff --git a/src/app/Recordings/Filters/LabelFilter.tsx b/src/app/Recordings/Filters/LabelFilter.tsx index 09c4699bc..9e66bc5ba 100644 --- a/src/app/Recordings/Filters/LabelFilter.tsx +++ b/src/app/Recordings/Filters/LabelFilter.tsx @@ -39,12 +39,15 @@ import React from 'react'; import { Label, Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import { ArchivedRecording } from '@app/Shared/Services/Api.service'; +import { parseLabels, RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; export interface LabelFilterProps { recordings: ArchivedRecording[]; onSubmit: (inputLabel: string) => void; } +export const getLabelDisplay = (label: RecordingLabel) => `${label.key}:${label.value}`; + export const LabelFilter: React.FunctionComponent = (props) => { const [isOpen, setIsOpen] = React.useState(false); const [selected, setSelected] = React.useState(''); @@ -68,9 +71,7 @@ export const LabelFilter: React.FunctionComponent = (props) => let updated = new Set(old); props.recordings.forEach((r) => { if (!r || !r.metadata) return; - Object.entries(r.metadata.labels).map(([k, v]) => - updated.add(`${k}:${v}`) - ); + parseLabels(r.metadata.labels).map((label) => updated.add(getLabelDisplay(label))); }); return Array.from(updated); }); From 3ddcaf3602456bb21507c0212e90525a2ea0ed94 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 6 Sep 2022 13:47:30 -0400 Subject: [PATCH 19/98] fix(filters): better handling of label filter search --- src/app/RecordingMetadata/LabelCell.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/app/RecordingMetadata/LabelCell.tsx b/src/app/RecordingMetadata/LabelCell.tsx index 54e6b29f3..6e8cb501b 100644 --- a/src/app/RecordingMetadata/LabelCell.tsx +++ b/src/app/RecordingMetadata/LabelCell.tsx @@ -56,11 +56,12 @@ export const LabelCell: React.FunctionComponent = (props) => { }: {}), [props.updateFilters]); + const labelFilterSet = React.useMemo(() => new Set(props.labelFilters), [props.labelFilters]); + const onLabelSelectToggle = React.useCallback( - (label) => { + (selectedLabel) => { if (props.updateFilters) { - const deleted = props.labelFilters && props.labelFilters.includes(label) - props.updateFilters({filterKey: "Labels", filterValue: label, deleted: deleted}) + props.updateFilters({filterKey: "Labels", filterValue: selectedLabel, deleted: labelFilterSet.has(selectedLabel)}) } }, [props.updateFilters, props.labelFilters]); @@ -68,14 +69,14 @@ export const LabelCell: React.FunctionComponent = (props) => { return ( <> {!!props.labels && props.labels.length? ( - props.labels.map((l) => + props.labels.map((label) => )) : ( - From 6fc5d7b9d84e63a6fbb7c8abb3a926664bc03793 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 7 Sep 2022 16:55:50 -0400 Subject: [PATCH 20/98] feat(storage): add utility func to store filter states to local storage --- src/app/Shared/Storage/LocalStorage.tsx | 68 +++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/app/Shared/Storage/LocalStorage.tsx diff --git a/src/app/Shared/Storage/LocalStorage.tsx b/src/app/Shared/Storage/LocalStorage.tsx new file mode 100644 index 000000000..a616f3aa5 --- /dev/null +++ b/src/app/Shared/Storage/LocalStorage.tsx @@ -0,0 +1,68 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export enum LocalStorageKey { + ACTIVE_RECORDING_FILTER, + ARCHIVED_RECORDING_FILTER +} + +/** + * This is equivalent to: + * type LocalStorageKeyStrings = 'ACTIVE_RECORDING_FILTER' | ARCHIVED_RECORDING_FILTER; + */ +type LocalStorageKeyStrings = keyof typeof LocalStorageKey; + +export const getFromLocalStorage = (key: LocalStorageKeyStrings, defaultValue: any): any => { + if (typeof window === "undefined") { + return defaultValue; + } + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : defaultValue; + } catch (error) { + return defaultValue; + } +} + +export const saveToLocalStorage = (key: LocalStorageKeyStrings, value: any) => { + try { + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(value)); + } + } catch (error) {} // If error (i.e. users disable storage for the site), saving is aborted and skipped. +} From 945dddd26d7d49c5b3a67dbf34c5da04b42842d2 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 7 Sep 2022 17:30:53 -0400 Subject: [PATCH 21/98] feat(storage): set up Redux for state management --- package.json | 2 + .../Shared/Redux/RecordingFilterActions.tsx | 99 +++++++++ .../Shared/Redux/RecordingFilterReducer.tsx | 190 ++++++++++++++++++ src/app/Shared/Redux/ReduxStore.tsx | 57 ++++++ yarn.lock | 69 ++++++- 5 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/app/Shared/Redux/RecordingFilterActions.tsx create mode 100644 src/app/Shared/Redux/RecordingFilterReducer.tsx create mode 100644 src/app/Shared/Redux/ReduxStore.tsx diff --git a/package.json b/package.json index 1b16fb6aa..fe72e0447 100644 --- a/package.json +++ b/package.json @@ -84,10 +84,12 @@ "@patternfly/react-icons": "^4.75.1", "@patternfly/react-styles": "^4.74.1", "@patternfly/react-table": "^4.93.1", + "@reduxjs/toolkit": "^1.8.5", "@types/lodash": "^4.14.175", "express": "^4.17.1", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-redux": "^8.0.2", "react-router-last-location": "^2.0.1" } } diff --git a/src/app/Shared/Redux/RecordingFilterActions.tsx b/src/app/Shared/Redux/RecordingFilterActions.tsx new file mode 100644 index 000000000..6066e13bb --- /dev/null +++ b/src/app/Shared/Redux/RecordingFilterActions.tsx @@ -0,0 +1,99 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { createAction } from "@reduxjs/toolkit"; +import { ArchivedRecording } from "../Services/Api.service"; + +export enum RecordingFilterAction { + FILTER_ADD = "filters/add", + FILTER_DELETE = "filters/delete", + FILTER_UPDATE = "filters/update", + CATEGORY_UPDATE = "category/update", + RECORDING_LIST_UPDATE = "recording_list/update", +} + +export interface RecordingFilterActionPayload { + category: string, + filter?: any, + isArchived: boolean +} + +export interface RecordingListActionPayload { + isArchived: boolean, + recordings: ArchivedRecording[] +} + +export const addFilterIntent = createAction(RecordingFilterAction.FILTER_ADD, (category: string, filter: any, isArchived: boolean) => ({ + payload: { + category: category, + filter: filter, + isArchived: isArchived + } as RecordingFilterActionPayload +})); + +export const deleteFilterIntent = createAction(RecordingFilterAction.FILTER_DELETE, (category: string, filter: any, isArchived: boolean) => ({ + payload: { + category: category, + filter: filter, + isArchived: isArchived + } as RecordingFilterActionPayload +})); + +export const deleteFiltersIntent = createAction(RecordingFilterAction.FILTER_DELETE, (category: string, isArchived: boolean) => ({ + payload: { + category: category, + isArchived: isArchived + } as RecordingFilterActionPayload +})); + + +export const updateCategoryIntent = createAction(RecordingFilterAction.CATEGORY_UPDATE, (category: string, isArchived: boolean) => ({ + payload: { + category: category, + isArchived: isArchived + } as RecordingFilterActionPayload +})); + +// Updates to recording list is subjected to current filters +export const updateRecordingListIntent = createAction(RecordingFilterAction.RECORDING_LIST_UPDATE, (recordings: ArchivedRecording[], isArchived: boolean) => ({ + payload: { + isArchived: isArchived, + recordings: recordings + } as RecordingListActionPayload +})); + diff --git a/src/app/Shared/Redux/RecordingFilterReducer.tsx b/src/app/Shared/Redux/RecordingFilterReducer.tsx new file mode 100644 index 000000000..db3363b1c --- /dev/null +++ b/src/app/Shared/Redux/RecordingFilterReducer.tsx @@ -0,0 +1,190 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { filterRecordings, RecordingFiltersCategories } from "@app/Recordings/RecordingFilters" +import { createReducer } from "@reduxjs/toolkit" +import { WritableDraft } from "immer/dist/internal"; +import { ArchivedRecording } from "../Services/Api.service"; +import { getFromLocalStorage, LocalStorageKey } from "../Storage/LocalStorage"; +import { addFilterIntent, deleteFilterIntent, deleteFiltersIntent, updateCategoryIntent, updateRecordingListIntent } from './RecordingFilterActions' + +export interface RecordingFilterStates { + selectedCategory: string, + filters: RecordingFiltersCategories, + recordings: ArchivedRecording[] +} + +const defaultActiveRecordingFilters = { + selectedCategory: "Name", + filters: { + Name: [], + Labels: [], + State: [], + StartedBeforeDate: [], + StartedAfterDate: [], + DurationSeconds: [], + } +}; + +const defaultArchivedRecordingFilters= { + selectedCategory: "Name", + filters: { + Name: [], + Labels: [], + } +}; + + +export interface UpdateFilterOptions { + filterKey: string; + filterValue?: any; + deleted?: boolean; + deleteOptions?: FilterDeleteOptions +} + +export interface FilterDeleteOptions { + all: boolean +} + +export const updateFilters = (old: RecordingFiltersCategories, {filterValue, filterKey, deleted = false, deleteOptions}: UpdateFilterOptions): RecordingFiltersCategories => { + if (!old[filterKey]) return old; + const oldFilterValues = old[filterKey] as any[]; + let newfilterValues: any[]; + if (deleted) { + if (deleteOptions && (deleteOptions as FilterDeleteOptions).all) { + newfilterValues = []; + } else { + newfilterValues = oldFilterValues.filter((val) => val !== filterValue); + } + } else { + newfilterValues = Array.from(new Set([...oldFilterValues, filterValue])); + } + + const newFilters = {...old}; + newFilters[filterKey] = newfilterValues; + return newFilters; +} + +export const getRecordingFilterStates = ( + state: WritableDraft<{ activeRecordingFilterStates: RecordingFilterStates; archivedRecordingFilterStates: RecordingFilterStates; }>, + isArchived: boolean): RecordingFilterStates => (isArchived? state.archivedRecordingFilterStates: state.activeRecordingFilterStates); + +export const updateRecordingFilterStates = ( + state: WritableDraft<{ activeRecordingFilterStates: RecordingFilterStates; archivedRecordingFilterStates: RecordingFilterStates; }>, + isArchived: boolean, + newFilterStates: RecordingFilterStates) => { + if (isArchived) { + state.archivedRecordingFilterStates = newFilterStates + } else { + state.activeRecordingFilterStates = newFilterStates; + } +} + +/** + * Note: Only filters are saved to local storage. Recordings are later fetched from api server. + */ + export const getSavedRecordingFilterStates = ({isArchived}): RecordingFilterStates => { + if (isArchived) { + return {...getFromLocalStorage("ARCHIVED_RECORDING_FILTER", defaultArchivedRecordingFilters), recordings: []} + } else { + return {...getFromLocalStorage("ACTIVE_RECORDING_FILTER", defaultActiveRecordingFilters), recordings: []} + } +} + +/** + * Initial states are loaded from local storage if there are any. + */ +const initialState = { + activeRecordingFilterStates: getSavedRecordingFilterStates({isArchived: false}), + archivedRecordingFilterStates: getSavedRecordingFilterStates({isArchived: false}), +} + +export const recordingFilterReducer = createReducer(initialState, (builder) => { + builder + .addCase(addFilterIntent, (state, {payload}) => { + const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); + + const newFilters = updateFilters(oldFilterStates.filters, {filterKey: payload.category, filterValue: payload.filter}); + const newRecordings = filterRecordings(oldFilterStates.recordings, newFilters); + + const newFilterStates = { + selectedCategory: payload.category, + filters: newFilters, + recordings: newRecordings + } as RecordingFilterStates; + + updateRecordingFilterStates(state, payload.isArchived, newFilterStates); + }) + .addCase(deleteFilterIntent, (state, {payload}) => { + const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); + + const newFilters = updateFilters(oldFilterStates.filters, {filterKey: payload.category, filterValue: payload.filter, deleted: true}); + const newRecordings = filterRecordings(oldFilterStates.recordings, newFilters); + + const newFilterStates = { + selectedCategory: payload.category, + filters: newFilters, + recordings: newRecordings + } as RecordingFilterStates; + + updateRecordingFilterStates(state, payload.isArchived, newFilterStates); + }) + .addCase(deleteFiltersIntent, (state, {payload}) => { + const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); + + const newFilters = updateFilters(oldFilterStates.filters, {filterKey: payload.category, filterValue: payload.filter, deleted: true, deleteOptions: {all: true}}); + const newRecordings = filterRecordings(oldFilterStates.recordings, newFilters); + + const newFilterStates = { + selectedCategory: payload.category, + filters: newFilters, + recordings: newRecordings + } as RecordingFilterStates; + + updateRecordingFilterStates(state, payload.isArchived, newFilterStates); + }) + .addCase(updateCategoryIntent, (state, {payload}) => { + const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); + oldFilterStates.selectedCategory = payload.category; + }) + .addCase(updateRecordingListIntent, (state, {payload}) => { + const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); + oldFilterStates.recordings = filterRecordings(payload.recordings, oldFilterStates.filters); + }); +}); + diff --git a/src/app/Shared/Redux/ReduxStore.tsx b/src/app/Shared/Redux/ReduxStore.tsx new file mode 100644 index 000000000..9967d0355 --- /dev/null +++ b/src/app/Shared/Redux/ReduxStore.tsx @@ -0,0 +1,57 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { configureStore } from "@reduxjs/toolkit"; +import { throttle } from "lodash"; +import { saveToLocalStorage } from "../Storage/LocalStorage"; +import { recordingFilterReducer, RecordingFilterStates } from "./RecordingFilterReducer"; + +export const store = configureStore({ + reducer: { + recordingFilter: recordingFilterReducer + } +}); + +export const saveFilterStates = (filterStates: any) => { + saveToLocalStorage("ARCHIVED_RECORDING_FILTER", filterStates.activeRecordingFilterStates); + saveToLocalStorage("ARCHIVED_RECORDING_FILTER", filterStates.archivedRecordingFilterStates); +} + +// Add a subscription to save filter states to local storage +// Every 500ms +store.subscribe(throttle(() => saveFilterStates(store.getState()), 500)); diff --git a/yarn.lock b/yarn.lock index e04ffcdff..1fdaa3ba4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -885,6 +885,16 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.12.tgz" integrity sha512-6RglhutqrGFMO1MNUXp95RBuYIuc8wTnMAV5MUhLmjTOy78ncwOw7RgeQ/HeymkKXRhZd0s2DNrM1rL7unk3MQ== +"@reduxjs/toolkit@^1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.8.5.tgz#c14bece03ee08be88467f22dc0ecf9cf875527cd" + integrity sha512-f4D5EXO7A7Xq35T0zRbWq5kJQyXzzscnHKmjnu2+37B3rwHU6mX9PYlbfXdnxcY6P/7zfmjhgan0Z+yuOfeBmA== + dependencies: + immer "^9.0.7" + redux "^4.1.2" + redux-thunk "^2.4.1" + reselect "^4.1.5" + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz" @@ -1123,6 +1133,14 @@ dependencies: "@types/node" "*" +"@types/hoist-non-react-statics@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" @@ -1297,6 +1315,11 @@ dependencies: "@types/jest" "*" +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@types/victory@^33.1.5": version "33.1.5" resolved "https://registry.yarnpkg.com/@types/victory/-/victory-33.1.5.tgz" @@ -3771,7 +3794,7 @@ history@^4.9.0: tiny-warning "^1.0.0" value-equal "^1.0.1" -hoist-non-react-statics@^3.1.0: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -3960,6 +3983,11 @@ imagemin@^8.0.1: replace-ext "^2.0.0" slash "^3.0.0" +immer@^9.0.7: + version "9.0.15" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.15.tgz#0b9169e5b1d22137aba7d43f8a81a495dd1b62dc" + integrity sha512-2eB/sswms9AEUSkOm4SbV5Y7Vmt/bKRwByd52jfLkW4OLYeaTP3EEiJ9agqU0O/tq6Dk62Zfj+TJSqfm1rLVGQ== + import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz" @@ -6117,6 +6145,23 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-is@^18.0.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" + integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== + +react-redux@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.0.2.tgz#bc2a304bb21e79c6808e3e47c50fe1caf62f7aad" + integrity sha512-nBwiscMw3NoP59NFCXFf02f8xdo+vSHT/uZ1ldDwF7XaTpzm+Phk97VT4urYBl5TYAPNVaFm12UHAEyzkpNzRA== + dependencies: + "@babel/runtime" "^7.12.1" + "@types/hoist-non-react-statics" "^3.3.1" + "@types/use-sync-external-store" "^0.0.3" + hoist-non-react-statics "^3.3.2" + react-is "^18.0.0" + use-sync-external-store "^1.0.0" + react-router-dom@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.3.0.tgz" @@ -6220,6 +6265,18 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux-thunk@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.1.tgz#0dd8042cf47868f4b29699941de03c9301a75714" + integrity sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q== + +redux@^4.1.2: + version "4.2.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13" + integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA== + dependencies: + "@babel/runtime" "^7.9.2" + reflect.ownkeys@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz" @@ -6294,6 +6351,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== +reselect@^4.1.5: + version "4.1.6" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.6.tgz#19ca2d3d0b35373a74dc1c98692cdaffb6602656" + integrity sha512-ZovIuXqto7elwnxyXbBtCPo9YFEr3uJqj2rRbcOOog1bmu2Ag85M4hixSwFWyaBMKXNgvPaJ9OSu9SkBPIeJHQ== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz" @@ -7249,6 +7311,11 @@ url-loader@^4.1.1: mime-types "^2.1.27" schema-utils "^3.0.0" +use-sync-external-store@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz" From 8141aa3b6904068189399edfc9975ceb4759271f Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 7 Sep 2022 17:45:24 -0400 Subject: [PATCH 22/98] fix(storage): only save recording filters to local storage --- src/app/Shared/Redux/ReduxStore.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/Shared/Redux/ReduxStore.tsx b/src/app/Shared/Redux/ReduxStore.tsx index 9967d0355..1e5084bd4 100644 --- a/src/app/Shared/Redux/ReduxStore.tsx +++ b/src/app/Shared/Redux/ReduxStore.tsx @@ -48,8 +48,16 @@ export const store = configureStore({ }); export const saveFilterStates = (filterStates: any) => { - saveToLocalStorage("ARCHIVED_RECORDING_FILTER", filterStates.activeRecordingFilterStates); - saveToLocalStorage("ARCHIVED_RECORDING_FILTER", filterStates.archivedRecordingFilterStates); + // Using anonymous functions to avoid name conflicts + (() => { + const {recordings, ...activeFiltersToSave } = filterStates.activeRecordingFilterStates as RecordingFilterStates; + saveToLocalStorage("ARCHIVED_RECORDING_FILTER", activeFiltersToSave); + })(); + + (() => { + const {recordings, ...archivedFiltersToSave } = filterStates.archivedRecordingFilterStates as RecordingFilterStates; + saveToLocalStorage("ARCHIVED_RECORDING_FILTER", archivedFiltersToSave); + })(); } // Add a subscription to save filter states to local storage From 68cd26134eeb2537ad53dc5e9257c748ecd95dc5 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 8 Sep 2022 00:34:57 -0400 Subject: [PATCH 23/98] fix(redux): redux should store unfiltered recordings --- src/app/Shared/Redux/RecordingFilterActions.tsx | 1 - src/app/Shared/Redux/RecordingFilterReducer.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/Shared/Redux/RecordingFilterActions.tsx b/src/app/Shared/Redux/RecordingFilterActions.tsx index 6066e13bb..5d3412b37 100644 --- a/src/app/Shared/Redux/RecordingFilterActions.tsx +++ b/src/app/Shared/Redux/RecordingFilterActions.tsx @@ -89,7 +89,6 @@ export const updateCategoryIntent = createAction(RecordingFilterAction.CATEGORY_ } as RecordingFilterActionPayload })); -// Updates to recording list is subjected to current filters export const updateRecordingListIntent = createAction(RecordingFilterAction.RECORDING_LIST_UPDATE, (recordings: ArchivedRecording[], isArchived: boolean) => ({ payload: { isArchived: isArchived, diff --git a/src/app/Shared/Redux/RecordingFilterReducer.tsx b/src/app/Shared/Redux/RecordingFilterReducer.tsx index db3363b1c..631fc21dc 100644 --- a/src/app/Shared/Redux/RecordingFilterReducer.tsx +++ b/src/app/Shared/Redux/RecordingFilterReducer.tsx @@ -46,7 +46,7 @@ import { addFilterIntent, deleteFilterIntent, deleteFiltersIntent, updateCategor export interface RecordingFilterStates { selectedCategory: string, filters: RecordingFiltersCategories, - recordings: ArchivedRecording[] + recordings: ArchivedRecording[] // Recordings are unfiltered } const defaultActiveRecordingFilters = { @@ -184,7 +184,7 @@ export const recordingFilterReducer = createReducer(initialState, (builder) => { }) .addCase(updateRecordingListIntent, (state, {payload}) => { const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); - oldFilterStates.recordings = filterRecordings(payload.recordings, oldFilterStates.filters); + oldFilterStates.recordings = payload.recordings; }); }); From 5acb2cb629dcce258c0c90eab126023264b4ad40 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 8 Sep 2022 01:27:09 -0400 Subject: [PATCH 24/98] chore(storage): move local storage utilities to util directory --- src/app/Shared/Redux/RecordingFilterReducer.tsx | 2 +- src/app/Shared/Redux/ReduxStore.tsx | 2 +- .../{Shared/Storage/LocalStorage.tsx => utils/LocalStorage.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/app/{Shared/Storage/LocalStorage.tsx => utils/LocalStorage.ts} (100%) diff --git a/src/app/Shared/Redux/RecordingFilterReducer.tsx b/src/app/Shared/Redux/RecordingFilterReducer.tsx index 631fc21dc..97d4de809 100644 --- a/src/app/Shared/Redux/RecordingFilterReducer.tsx +++ b/src/app/Shared/Redux/RecordingFilterReducer.tsx @@ -40,7 +40,7 @@ import { filterRecordings, RecordingFiltersCategories } from "@app/Recordings/Re import { createReducer } from "@reduxjs/toolkit" import { WritableDraft } from "immer/dist/internal"; import { ArchivedRecording } from "../Services/Api.service"; -import { getFromLocalStorage, LocalStorageKey } from "../Storage/LocalStorage"; +import { getFromLocalStorage } from "../../utils/LocalStorage"; import { addFilterIntent, deleteFilterIntent, deleteFiltersIntent, updateCategoryIntent, updateRecordingListIntent } from './RecordingFilterActions' export interface RecordingFilterStates { diff --git a/src/app/Shared/Redux/ReduxStore.tsx b/src/app/Shared/Redux/ReduxStore.tsx index 1e5084bd4..6aded505c 100644 --- a/src/app/Shared/Redux/ReduxStore.tsx +++ b/src/app/Shared/Redux/ReduxStore.tsx @@ -38,7 +38,7 @@ import { configureStore } from "@reduxjs/toolkit"; import { throttle } from "lodash"; -import { saveToLocalStorage } from "../Storage/LocalStorage"; +import { saveToLocalStorage } from "../../utils/LocalStorage"; import { recordingFilterReducer, RecordingFilterStates } from "./RecordingFilterReducer"; export const store = configureStore({ diff --git a/src/app/Shared/Storage/LocalStorage.tsx b/src/app/utils/LocalStorage.ts similarity index 100% rename from src/app/Shared/Storage/LocalStorage.tsx rename to src/app/utils/LocalStorage.ts From 3af10a3acf631e920e9519f4a9cade18c80d73b2 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 8 Sep 2022 01:34:40 -0400 Subject: [PATCH 25/98] tmp: save selected indices of recordings to redux store --- src/app/Shared/Redux/RecordingFilterActions.tsx | 12 ++++++++++++ src/app/Shared/Redux/RecordingFilterReducer.tsx | 12 ++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/Shared/Redux/RecordingFilterActions.tsx b/src/app/Shared/Redux/RecordingFilterActions.tsx index 5d3412b37..9efc17305 100644 --- a/src/app/Shared/Redux/RecordingFilterActions.tsx +++ b/src/app/Shared/Redux/RecordingFilterActions.tsx @@ -45,6 +45,7 @@ export enum RecordingFilterAction { FILTER_UPDATE = "filters/update", CATEGORY_UPDATE = "category/update", RECORDING_LIST_UPDATE = "recording_list/update", + SELECTED_ROWS_UPDATE = "selected_recording_row/update" } export interface RecordingFilterActionPayload { @@ -58,6 +59,11 @@ export interface RecordingListActionPayload { recordings: ArchivedRecording[] } +export interface SelectedRowsActionPayload { + isArchived: boolean, + indices: number[] +} + export const addFilterIntent = createAction(RecordingFilterAction.FILTER_ADD, (category: string, filter: any, isArchived: boolean) => ({ payload: { category: category, @@ -96,3 +102,9 @@ export const updateRecordingListIntent = createAction(RecordingFilterAction.RECO } as RecordingListActionPayload })); +export const updateSelectedRowIndicesIntent = createAction(RecordingFilterAction.SELECTED_ROWS_UPDATE, (indices: number[], isArchived: boolean) => ({ + payload: { + isArchived: isArchived, + indices: indices + } as SelectedRowsActionPayload +})); diff --git a/src/app/Shared/Redux/RecordingFilterReducer.tsx b/src/app/Shared/Redux/RecordingFilterReducer.tsx index 97d4de809..1454542b7 100644 --- a/src/app/Shared/Redux/RecordingFilterReducer.tsx +++ b/src/app/Shared/Redux/RecordingFilterReducer.tsx @@ -41,12 +41,13 @@ import { createReducer } from "@reduxjs/toolkit" import { WritableDraft } from "immer/dist/internal"; import { ArchivedRecording } from "../Services/Api.service"; import { getFromLocalStorage } from "../../utils/LocalStorage"; -import { addFilterIntent, deleteFilterIntent, deleteFiltersIntent, updateCategoryIntent, updateRecordingListIntent } from './RecordingFilterActions' +import { addFilterIntent, deleteFilterIntent, deleteFiltersIntent, updateCategoryIntent, updateRecordingListIntent, updateSelectedRowIndicesIntent } from './RecordingFilterActions' export interface RecordingFilterStates { selectedCategory: string, filters: RecordingFiltersCategories, recordings: ArchivedRecording[] // Recordings are unfiltered + selectedRowIndices: number[] // The indices of selected recording rows } const defaultActiveRecordingFilters = { @@ -69,7 +70,6 @@ const defaultArchivedRecordingFilters= { } }; - export interface UpdateFilterOptions { filterKey: string; filterValue?: any; @@ -120,9 +120,9 @@ export const updateRecordingFilterStates = ( */ export const getSavedRecordingFilterStates = ({isArchived}): RecordingFilterStates => { if (isArchived) { - return {...getFromLocalStorage("ARCHIVED_RECORDING_FILTER", defaultArchivedRecordingFilters), recordings: []} + return {...getFromLocalStorage("ARCHIVED_RECORDING_FILTER", defaultArchivedRecordingFilters), recordings: [], selectedRowIndices: []} } else { - return {...getFromLocalStorage("ACTIVE_RECORDING_FILTER", defaultActiveRecordingFilters), recordings: []} + return {...getFromLocalStorage("ACTIVE_RECORDING_FILTER", defaultActiveRecordingFilters), recordings: [], selectedRowIndices: []} } } @@ -185,6 +185,10 @@ export const recordingFilterReducer = createReducer(initialState, (builder) => { .addCase(updateRecordingListIntent, (state, {payload}) => { const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); oldFilterStates.recordings = payload.recordings; + }) + .addCase(updateSelectedRowIndicesIntent, (state, {payload}) => { + const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); + oldFilterStates.selectedRowIndices = payload.indices; }); }); From 566fabecac31baacd4ef766a0146b6ea66affe6d Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Fri, 9 Sep 2022 15:23:26 -0400 Subject: [PATCH 26/98] fix(redux): update redux actions and reducers --- .../Shared/Redux/RecordingFilterActions.tsx | 59 ++-- .../Shared/Redux/RecordingFilterReducer.tsx | 278 ++++++++++-------- src/app/Shared/Redux/ReduxStore.tsx | 23 +- 3 files changed, 188 insertions(+), 172 deletions(-) diff --git a/src/app/Shared/Redux/RecordingFilterActions.tsx b/src/app/Shared/Redux/RecordingFilterActions.tsx index 9efc17305..4edb116ff 100644 --- a/src/app/Shared/Redux/RecordingFilterActions.tsx +++ b/src/app/Shared/Redux/RecordingFilterActions.tsx @@ -37,74 +37,73 @@ */ import { createAction } from "@reduxjs/toolkit"; -import { ArchivedRecording } from "../Services/Api.service"; +// Common action string format: "resource(s)/action" export enum RecordingFilterAction { - FILTER_ADD = "filters/add", - FILTER_DELETE = "filters/delete", - FILTER_UPDATE = "filters/update", + FILTER_ADD = "filter/add", + FILTER_DELETE = "filter/delete", + FILTER_DELETE_ALL = "filter/delete_all", // Delete all filters in all categories + CATEGORY_FILTERS_DELETE ="filters/delete", // Delete all filters of the same category CATEGORY_UPDATE = "category/update", - RECORDING_LIST_UPDATE = "recording_list/update", - SELECTED_ROWS_UPDATE = "selected_recording_row/update" + TARGET_ADD = "target/add", + TARGET_DELETE = "target/delete" } export interface RecordingFilterActionPayload { - category: string, + target: string, + category?: string, filter?: any, - isArchived: boolean + isArchived?: boolean } -export interface RecordingListActionPayload { - isArchived: boolean, - recordings: ArchivedRecording[] -} - -export interface SelectedRowsActionPayload { - isArchived: boolean, - indices: number[] -} - -export const addFilterIntent = createAction(RecordingFilterAction.FILTER_ADD, (category: string, filter: any, isArchived: boolean) => ({ +export const addFilterIntent = createAction(RecordingFilterAction.FILTER_ADD, (target: string, category: string, filter: any, isArchived: boolean) => ({ payload: { + target: target, category: category, filter: filter, isArchived: isArchived } as RecordingFilterActionPayload })); -export const deleteFilterIntent = createAction(RecordingFilterAction.FILTER_DELETE, (category: string, filter: any, isArchived: boolean) => ({ +export const deleteFilterIntent = createAction(RecordingFilterAction.FILTER_DELETE, (target: string, category: string, filter: any, isArchived: boolean) => ({ payload: { + target: target, category: category, filter: filter, isArchived: isArchived } as RecordingFilterActionPayload })); -export const deleteFiltersIntent = createAction(RecordingFilterAction.FILTER_DELETE, (category: string, isArchived: boolean) => ({ +export const deleteCategoryFiltersIntent = createAction(RecordingFilterAction.CATEGORY_FILTERS_DELETE, (target: string, category: string, isArchived: boolean) => ({ payload: { + target: target, category: category, isArchived: isArchived } as RecordingFilterActionPayload })); +export const deleteAllFiltersIntent = createAction(RecordingFilterAction.FILTER_DELETE_ALL, (target: string) => ({ + payload: { + target: target, + } as RecordingFilterActionPayload +})); -export const updateCategoryIntent = createAction(RecordingFilterAction.CATEGORY_UPDATE, (category: string, isArchived: boolean) => ({ +export const updateCategoryIntent = createAction(RecordingFilterAction.CATEGORY_UPDATE, (target: string, category: string, isArchived: boolean) => ({ payload: { + target: target, category: category, isArchived: isArchived } as RecordingFilterActionPayload })); -export const updateRecordingListIntent = createAction(RecordingFilterAction.RECORDING_LIST_UPDATE, (recordings: ArchivedRecording[], isArchived: boolean) => ({ +export const addTargetIntent = createAction(RecordingFilterAction.TARGET_ADD, (target: string) => ({ payload: { - isArchived: isArchived, - recordings: recordings - } as RecordingListActionPayload + target: target, + } as RecordingFilterActionPayload })); -export const updateSelectedRowIndicesIntent = createAction(RecordingFilterAction.SELECTED_ROWS_UPDATE, (indices: number[], isArchived: boolean) => ({ +export const deleteTargetIntent = createAction(RecordingFilterAction.TARGET_DELETE, (target: string) => ({ payload: { - isArchived: isArchived, - indices: indices - } as SelectedRowsActionPayload + target: target, + } as RecordingFilterActionPayload })); diff --git a/src/app/Shared/Redux/RecordingFilterReducer.tsx b/src/app/Shared/Redux/RecordingFilterReducer.tsx index 1454542b7..3a796b112 100644 --- a/src/app/Shared/Redux/RecordingFilterReducer.tsx +++ b/src/app/Shared/Redux/RecordingFilterReducer.tsx @@ -36,63 +36,50 @@ * SOFTWARE. */ -import { filterRecordings, RecordingFiltersCategories } from "@app/Recordings/RecordingFilters" +import { emptyActiveRecordingFilters, emptyArchivedRecordingFilters, RecordingFiltersCategories } from "@app/Recordings/RecordingFilters" import { createReducer } from "@reduxjs/toolkit" import { WritableDraft } from "immer/dist/internal"; -import { ArchivedRecording } from "../Services/Api.service"; -import { getFromLocalStorage } from "../../utils/LocalStorage"; -import { addFilterIntent, deleteFilterIntent, deleteFiltersIntent, updateCategoryIntent, updateRecordingListIntent, updateSelectedRowIndicesIntent } from './RecordingFilterActions' - -export interface RecordingFilterStates { - selectedCategory: string, - filters: RecordingFiltersCategories, - recordings: ArchivedRecording[] // Recordings are unfiltered - selectedRowIndices: number[] // The indices of selected recording rows -} - -const defaultActiveRecordingFilters = { - selectedCategory: "Name", - filters: { - Name: [], - Labels: [], - State: [], - StartedBeforeDate: [], - StartedAfterDate: [], - DurationSeconds: [], - } -}; - -const defaultArchivedRecordingFilters= { - selectedCategory: "Name", - filters: { - Name: [], - Labels: [], +import { addFilterIntent, addTargetIntent, deleteAllFiltersIntent, deleteCategoryFiltersIntent, deleteFilterIntent, deleteTargetIntent, updateCategoryIntent, } from './RecordingFilterActions'; + +export interface TargetRecordingFilters { + target: string, // connectURL + active: { // active recordings + selectedCategory?: string, + filters: RecordingFiltersCategories, + }, + archived: { // archived recordings + selectedCategory?: string, + filters: RecordingFiltersCategories, } -}; +} export interface UpdateFilterOptions { filterKey: string; filterValue?: any; deleted?: boolean; - deleteOptions?: FilterDeleteOptions -} - -export interface FilterDeleteOptions { - all: boolean + deleteOptions?: { + all: boolean + } } -export const updateFilters = (old: RecordingFiltersCategories, {filterValue, filterKey, deleted = false, deleteOptions}: UpdateFilterOptions): RecordingFiltersCategories => { - if (!old[filterKey]) return old; - const oldFilterValues = old[filterKey] as any[]; +export const createOrUpdateRecordingFilter = ( + old: RecordingFiltersCategories, + {filterValue, filterKey, deleted = false, deleteOptions}: UpdateFilterOptions): RecordingFiltersCategories => { + let newfilterValues: any[]; - if (deleted) { - if (deleteOptions && (deleteOptions as FilterDeleteOptions).all) { - newfilterValues = []; + if (!old[filterKey]) { + newfilterValues = []; + } else { + const oldFilterValues = old[filterKey] as any[]; + if (deleted) { + if (deleteOptions && deleteOptions.all) { + newfilterValues = []; + } else { + newfilterValues = oldFilterValues.filter((val) => val !== filterValue); + } } else { - newfilterValues = oldFilterValues.filter((val) => val !== filterValue); + newfilterValues = Array.from(new Set([...oldFilterValues, filterValue])); } - } else { - newfilterValues = Array.from(new Set([...oldFilterValues, filterValue])); } const newFilters = {...old}; @@ -100,95 +87,144 @@ export const updateFilters = (old: RecordingFiltersCategories, {filterValue, fil return newFilters; } -export const getRecordingFilterStates = ( - state: WritableDraft<{ activeRecordingFilterStates: RecordingFilterStates; archivedRecordingFilterStates: RecordingFilterStates; }>, - isArchived: boolean): RecordingFilterStates => (isArchived? state.archivedRecordingFilterStates: state.activeRecordingFilterStates); - -export const updateRecordingFilterStates = ( - state: WritableDraft<{ activeRecordingFilterStates: RecordingFilterStates; archivedRecordingFilterStates: RecordingFilterStates; }>, - isArchived: boolean, - newFilterStates: RecordingFilterStates) => { - if (isArchived) { - state.archivedRecordingFilterStates = newFilterStates - } else { - state.activeRecordingFilterStates = newFilterStates; - } -} +export const getTargetRecordingFilter = (state: WritableDraft<{ list: TargetRecordingFilters[]; }>, target: string): TargetRecordingFilters => { + const targetFilter = state.list.filter((targetFilters) => targetFilters.target === target); + return targetFilter.length > 0? targetFilter[0]: createEmptyTargetRecordingFilters(target); +}; -/** - * Note: Only filters are saved to local storage. Recordings are later fetched from api server. - */ - export const getSavedRecordingFilterStates = ({isArchived}): RecordingFilterStates => { - if (isArchived) { - return {...getFromLocalStorage("ARCHIVED_RECORDING_FILTER", defaultArchivedRecordingFilters), recordings: [], selectedRowIndices: []} - } else { - return {...getFromLocalStorage("ACTIVE_RECORDING_FILTER", defaultActiveRecordingFilters), recordings: [], selectedRowIndices: []} - } -} +export const createEmptyTargetRecordingFilters = (target: string) => ( + { + target: target, + active: { + selectedCategory: "Name", + filters: emptyActiveRecordingFilters, + }, + archived: { + selectedCategory: "Name", + filters: emptyArchivedRecordingFilters, + } + } as TargetRecordingFilters +); -/** - * Initial states are loaded from local storage if there are any. - */ -const initialState = { - activeRecordingFilterStates: getSavedRecordingFilterStates({isArchived: false}), - archivedRecordingFilterStates: getSavedRecordingFilterStates({isArchived: false}), -} +// Initial states are loaded from local storage if there are any (TODO) +const initialState = { list: [] as TargetRecordingFilters[] }; export const recordingFilterReducer = createReducer(initialState, (builder) => { builder .addCase(addFilterIntent, (state, {payload}) => { - const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); - - const newFilters = updateFilters(oldFilterStates.filters, {filterKey: payload.category, filterValue: payload.filter}); - const newRecordings = filterRecordings(oldFilterStates.recordings, newFilters); - - const newFilterStates = { - selectedCategory: payload.category, - filters: newFilters, - recordings: newRecordings - } as RecordingFilterStates; - - updateRecordingFilterStates(state, payload.isArchived, newFilterStates); + const oldTargetRecordingFilter = getTargetRecordingFilter(state, payload.target); + + let newTargetRecordingFilter: TargetRecordingFilters; + if (payload.isArchived) { + newTargetRecordingFilter = { + ...oldTargetRecordingFilter, + archived: { + selectedCategory: payload.category, + filters: createOrUpdateRecordingFilter(oldTargetRecordingFilter.archived.filters, { + filterKey: payload.category!, + filterValue: payload.filter + }) + }} + } else { + newTargetRecordingFilter = { + ...oldTargetRecordingFilter, + active: { + selectedCategory: payload.category, + filters: createOrUpdateRecordingFilter(oldTargetRecordingFilter.active.filters, { + filterKey: payload.category!, + filterValue: payload.filter + }) + }} + } + + state.list = state.list.filter((targetFilters) => targetFilters.target !== newTargetRecordingFilter.target); + state.list.push(newTargetRecordingFilter); }) .addCase(deleteFilterIntent, (state, {payload}) => { - const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); - - const newFilters = updateFilters(oldFilterStates.filters, {filterKey: payload.category, filterValue: payload.filter, deleted: true}); - const newRecordings = filterRecordings(oldFilterStates.recordings, newFilters); - - const newFilterStates = { - selectedCategory: payload.category, - filters: newFilters, - recordings: newRecordings - } as RecordingFilterStates; - - updateRecordingFilterStates(state, payload.isArchived, newFilterStates); + const oldTargetRecordingFilter = getTargetRecordingFilter(state, payload.target); + + let newTargetRecordingFilter: TargetRecordingFilters; + if (payload.isArchived) { + newTargetRecordingFilter = { + ...oldTargetRecordingFilter, + archived: { + selectedCategory: payload.category, + filters: createOrUpdateRecordingFilter(oldTargetRecordingFilter.archived.filters, { + filterKey: payload.category!, + filterValue: payload.filter, + deleted: true + }) + }} + } else { + newTargetRecordingFilter = { + ...oldTargetRecordingFilter, + active: { + selectedCategory: payload.category, + filters: createOrUpdateRecordingFilter(oldTargetRecordingFilter.active.filters, { + filterKey: payload.category!, + filterValue: payload.filter, + deleted: true + }) + }} + } + + state.list = state.list.filter((targetFilters) => targetFilters.target !== newTargetRecordingFilter.target); + state.list.push(newTargetRecordingFilter); }) - .addCase(deleteFiltersIntent, (state, {payload}) => { - const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); - - const newFilters = updateFilters(oldFilterStates.filters, {filterKey: payload.category, filterValue: payload.filter, deleted: true, deleteOptions: {all: true}}); - const newRecordings = filterRecordings(oldFilterStates.recordings, newFilters); - - const newFilterStates = { - selectedCategory: payload.category, - filters: newFilters, - recordings: newRecordings - } as RecordingFilterStates; - - updateRecordingFilterStates(state, payload.isArchived, newFilterStates); + .addCase(deleteCategoryFiltersIntent, (state, {payload}) => { + const oldTargetRecordingFilter = getTargetRecordingFilter(state, payload.target); + + let newTargetRecordingFilter: TargetRecordingFilters; + if (payload.isArchived) { + newTargetRecordingFilter = { + ...oldTargetRecordingFilter, + archived: { + selectedCategory: payload.category, + filters: createOrUpdateRecordingFilter(oldTargetRecordingFilter.archived.filters, { + filterKey: payload.category!, + deleted: true, + deleteOptions: {all: true} + }) + }} + } else { + newTargetRecordingFilter = { + ...oldTargetRecordingFilter, + active: { + selectedCategory: payload.category, + filters: createOrUpdateRecordingFilter(oldTargetRecordingFilter.active.filters, { + filterKey: payload.category!, + deleted: true, + deleteOptions: {all: true} + }) + }} + } + + state.list = state.list.filter((targetFilters) => targetFilters.target !== newTargetRecordingFilter.target); + state.list.push(newTargetRecordingFilter); + }) + .addCase(deleteAllFiltersIntent, (state, {payload}) => { + const newTargetRecordingFilter = createEmptyTargetRecordingFilters(payload.target); + state.list = state.list.filter((targetFilters) => targetFilters.target !== newTargetRecordingFilter.target); + state.list.push(newTargetRecordingFilter); }) .addCase(updateCategoryIntent, (state, {payload}) => { - const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); - oldFilterStates.selectedCategory = payload.category; + const oldTargetRecordingFilter = getTargetRecordingFilter(state, payload.target); + const newTargetRecordingFilter = {...oldTargetRecordingFilter}; + if (payload.isArchived) { + newTargetRecordingFilter.archived.selectedCategory = payload.category; + } else { + newTargetRecordingFilter.active.selectedCategory = payload.category; + } + state.list = state.list.filter((targetFilters) => targetFilters.target !== newTargetRecordingFilter.target); + state.list.push(newTargetRecordingFilter); + }) + .addCase(addTargetIntent, (state, {payload}) => { + const targetRecordingFilter = getTargetRecordingFilter(state, payload.target); + state.list = state.list.filter((targetFilters) => targetFilters.target !== payload.target); + state.list.push(targetRecordingFilter); }) - .addCase(updateRecordingListIntent, (state, {payload}) => { - const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); - oldFilterStates.recordings = payload.recordings; + .addCase(deleteTargetIntent, (state, {payload}) => { + state.list = state.list.filter((targetFilters) => targetFilters.target !== payload.target); }) - .addCase(updateSelectedRowIndicesIntent, (state, {payload}) => { - const oldFilterStates = getRecordingFilterStates(state, payload.isArchived); - oldFilterStates.selectedRowIndices = payload.indices; - }); }); diff --git a/src/app/Shared/Redux/ReduxStore.tsx b/src/app/Shared/Redux/ReduxStore.tsx index 6aded505c..3c909e3bb 100644 --- a/src/app/Shared/Redux/ReduxStore.tsx +++ b/src/app/Shared/Redux/ReduxStore.tsx @@ -37,29 +37,10 @@ */ import { configureStore } from "@reduxjs/toolkit"; -import { throttle } from "lodash"; -import { saveToLocalStorage } from "../../utils/LocalStorage"; -import { recordingFilterReducer, RecordingFilterStates } from "./RecordingFilterReducer"; +import { recordingFilterReducer as recordingFiltersReducer } from "./RecordingFilterReducer"; export const store = configureStore({ reducer: { - recordingFilter: recordingFilterReducer + recordingFilters: recordingFiltersReducer } }); - -export const saveFilterStates = (filterStates: any) => { - // Using anonymous functions to avoid name conflicts - (() => { - const {recordings, ...activeFiltersToSave } = filterStates.activeRecordingFilterStates as RecordingFilterStates; - saveToLocalStorage("ARCHIVED_RECORDING_FILTER", activeFiltersToSave); - })(); - - (() => { - const {recordings, ...archivedFiltersToSave } = filterStates.archivedRecordingFilterStates as RecordingFilterStates; - saveToLocalStorage("ARCHIVED_RECORDING_FILTER", archivedFiltersToSave); - })(); -} - -// Add a subscription to save filter states to local storage -// Every 500ms -store.subscribe(throttle(() => saveFilterStates(store.getState()), 500)); From a22363b85224c783ff49fdf262eb3314b867c101 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Fri, 9 Sep 2022 18:57:34 -0400 Subject: [PATCH 27/98] feat(filters): recording filters now persist across views --- src/app/RecordingMetadata/BulkEditLabels.tsx | 2 +- src/app/RecordingMetadata/LabelCell.tsx | 9 +- src/app/Recordings/ActiveRecordingsTable.tsx | 122 +++++++++--------- .../Recordings/ArchivedRecordingsTable.tsx | 95 +++++++------- src/app/Recordings/RecordingFilters.tsx | 86 +++++++----- src/app/index.tsx | 14 +- .../RecordingMetadata.tsx/LabelCell.test.tsx | 4 +- 7 files changed, 175 insertions(+), 157 deletions(-) diff --git a/src/app/RecordingMetadata/BulkEditLabels.tsx b/src/app/RecordingMetadata/BulkEditLabels.tsx index 5c4551296..ea0eb9e50 100644 --- a/src/app/RecordingMetadata/BulkEditLabels.tsx +++ b/src/app/RecordingMetadata/BulkEditLabels.tsx @@ -236,7 +236,7 @@ export const BulkEditLabels: React.FunctionComponent = (pro - + {editing ? ( diff --git a/src/app/RecordingMetadata/LabelCell.tsx b/src/app/RecordingMetadata/LabelCell.tsx index 6e8cb501b..4fef5bdf5 100644 --- a/src/app/RecordingMetadata/LabelCell.tsx +++ b/src/app/RecordingMetadata/LabelCell.tsx @@ -37,17 +37,18 @@ */ import { getLabelDisplay } from '@app/Recordings/Filters/LabelFilter'; -import { UpdateFilterOptions } from '@app/Recordings/RecordingFilters'; +import { UpdateFilterOptions } from '@app/Shared/Redux/RecordingFilterReducer'; import { Label, Text } from '@patternfly/react-core'; import React from 'react'; import { RecordingLabel } from './RecordingLabel'; export interface LabelCellProps { + target: string, labels: RecordingLabel[]; // Must be specified along with updateFilters. labelFilters?: string[]; // If undefined, labels are not clickable (i.e. display only). - updateFilters?: (updateFilterOptions: UpdateFilterOptions) => void + updateFilters?: (target: string, updateFilterOptions: UpdateFilterOptions) => void } export const LabelCell: React.FunctionComponent = (props) => { @@ -61,10 +62,10 @@ export const LabelCell: React.FunctionComponent = (props) => { const onLabelSelectToggle = React.useCallback( (selectedLabel) => { if (props.updateFilters) { - props.updateFilters({filterKey: "Labels", filterValue: selectedLabel, deleted: labelFilterSet.has(selectedLabel)}) + props.updateFilters(props.target, {filterKey: "Labels", filterValue: selectedLabel, deleted: labelFilterSet.has(selectedLabel)}) } }, - [props.updateFilters, props.labelFilters]); + [props.updateFilters, props.labelFilters, props.target]); return ( <> diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 03a045288..e896d8ed8 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -51,11 +51,14 @@ import { concatMap, filter, first } from 'rxjs/operators'; import { LabelCell } from '../RecordingMetadata/LabelCell'; import { RecordingActions } from './RecordingActions'; import { RecordingLabelsPanel } from './RecordingLabelsPanel'; -import { FilterDeleteOptions, filterRecordings, RecordingFilters, RecordingFiltersCategories } from './RecordingFilters'; +import { emptyActiveRecordingFilters, filterRecordings, RecordingFilters, RecordingFiltersCategories } from './RecordingFilters'; import { RecordingsTable } from './RecordingsTable'; import { ReportFrame } from './ReportFrame'; import { DeleteWarningModal } from '../Modal/DeleteWarningModal'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; +import { useDispatch, useSelector } from 'react-redux'; +import { addFilterIntent, addTargetIntent, deleteAllFiltersIntent, deleteCategoryFiltersIntent, deleteFilterIntent } from '@app/Shared/Redux/RecordingFilterActions'; +import { TargetRecordingFilters } from '@app/Shared/Redux/RecordingFilterReducer'; export enum PanelContent { LABELS, @@ -67,7 +70,11 @@ export interface ActiveRecordingsTableProps { export const ActiveRecordingsTable: React.FunctionComponent = (props) => { const context = React.useContext(ServiceContext); const routerHistory = useHistory(); + const { url } = useRouteMatch(); + const addSubscription = useSubscriptions(); + const disPatch = useDispatch(); + const [target, setTarget] = React.useState(""); // connectURL of the target const [recordings, setRecordings] = React.useState([] as ActiveRecording[]); const [filteredRecordings, setFilteredRecordings] = React.useState([] as ActiveRecording[]); const [headerChecked, setHeaderChecked] = React.useState(false); @@ -76,18 +83,13 @@ export const ActiveRecordingsTable: React.FunctionComponent { + const filters = state.recordingFilters.list.filter((targetFilter: TargetRecordingFilters) => targetFilter.target === target); + return filters.length > 0? filters[0].active.filters: emptyActiveRecordingFilters; + }) as RecordingFiltersCategories; const tableColumns: string[] = [ 'Name', @@ -97,8 +99,6 @@ export const ActiveRecordingsTable: React.FunctionComponent { if (checked) { setCheckedIndices(ci => ([...ci, index])); @@ -147,9 +147,13 @@ export const ActiveRecordingsTable: React.FunctionComponent { addSubscription( - context.target.target().subscribe(refreshRecordingList) + context.target.target().subscribe((target) => { + setTarget(target.connectUrl); + disPatch(addTargetIntent(target.connectUrl)); + refreshRecordingList(); + }) ); - }, [addSubscription, context, context.target, refreshRecordingList]); + }, [addSubscription, context, context.target, refreshRecordingList, setTarget]); React.useEffect(() => { addSubscription( @@ -304,45 +308,25 @@ export const ActiveRecordingsTable: React.FunctionComponent { - setFilters({ - Name: [], - Labels: [], - State: [], - StartedBeforeDate: [], - StartedAfterDate: [], - DurationSeconds: [], - } as RecordingFiltersCategories); - }, [setFilters]); - - const updateFilters = React.useCallback(({filterValue, filterKey, deleted = false, deleteOptions}) => { - setCurrentFilterCategory(filterKey); - setFilters((old) => { - if (!old[filterKey]) return old; - - const oldFilterValues = old[filterKey] as any[]; - let newfilterValues: any[]; - if (deleted) { - if (deleteOptions && (deleteOptions as FilterDeleteOptions).all) { - newfilterValues = []; - } else { - newfilterValues = oldFilterValues.filter((val) => val !== filterValue); - } + disPatch(deleteAllFiltersIntent(target)); + }, [disPatch, target]); + + const updateFilters = React.useCallback((target, {filterValue, filterKey, deleted = false, deleteOptions}) => { + if (deleted) { + if (deleteOptions && deleteOptions.all) { + disPatch(deleteCategoryFiltersIntent(target, filterKey, false)); } else { - newfilterValues = Array.from(new Set([...oldFilterValues, filterValue])); + disPatch(deleteFilterIntent(target, filterKey, filterValue, false)); } - - const newFilters = {...old}; - newFilters[filterKey] = newfilterValues; - return newFilters; - }); - - }, [setCurrentFilterCategory, setFilters]); + } else { + disPatch(addFilterIntent(target, filterKey, filterValue, false)); + } + }, [disPatch]); React.useEffect(() => { - setFilteredRecordings(filterRecordings(recordings, filters)); - }, [recordings, filters]); + setFilteredRecordings(filterRecordings(recordings, targetRecordingFilters)); + }, [recordings, targetRecordingFilters]); React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { @@ -357,28 +341,27 @@ export const ActiveRecordingsTable: React.FunctionComponent `active-table-row-${props.recording.name}-${props.recording.startTime}-exp`, + [props.recording.name, props.recording.startTime]); - const handleToggle = () => { - toggleExpanded(expandedRowId); - }; + const handleToggle = React.useCallback(() => toggleExpanded(expandedRowId), [toggleExpanded]); const isExpanded = React.useMemo(() => { return expandedRows.includes(expandedRowId) }, [expandedRows, expandedRowId]); - const handleCheck = (checked) => { + const handleCheck = React.useCallback((checked) => { handleRowCheck(checked, props.index); - }; + }, [handleRowCheck, props.index]); const parentRow = React.useMemo(() => { const ISOTime = (props) => { - const fmt = new Date(props.timeStr).toISOString(); + const fmt = React.useMemo(() => new Date(props.timeStr).toISOString(), [props.timeStr]); return ({fmt}); }; const RecordingDuration = (props) => { - const str = props.duration === 0 ? 'Continuous' : `${props.duration / 1000}s`; + const str = React.useMemo(() => props.duration === 0 ? 'Continuous' : `${props.duration / 1000}s`, [props.duration]); return ({str}); }; @@ -416,6 +399,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { @@ -480,10 +469,12 @@ export const ActiveRecordingsTable: React.FunctionComponent { - const idx = expandedRows.indexOf(id); - setExpandedRows(expandedRows => idx >= 0 ? [...expandedRows.slice(0, idx), ...expandedRows.slice(idx + 1, expandedRows.length)] : [...expandedRows, id]); - }; + const toggleExpanded = React.useCallback((id: string) => { + setExpandedRows(expandedRows => { + const idx = expandedRows.indexOf(id); + return idx >= 0 ? [...expandedRows.slice(0, idx), ...expandedRows.slice(idx + 1, expandedRows.length)] : [...expandedRows, id] + }); + }, [expandedRows, setExpandedRows]); const handleDeleteButton = React.useCallback(() => { if (context.settings.deletionDialogsEnabledFor(DeleteWarningType.DeleteActiveRecordings)) { @@ -549,7 +540,12 @@ export const ActiveRecordingsTable: React.FunctionComponent - + { buttons } { deleteActiveWarningModal } @@ -558,7 +554,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { - return filteredRecordings.map((r, idx) => ) + return filteredRecordings.map((r, idx) => ) }, [filteredRecordings, expandedRows, checkedIndices]); const LabelsPanel = React.useMemo(() => ( diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index 9d17cbcb1..1e8151592 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -54,9 +54,12 @@ import { LabelCell } from '../RecordingMetadata/LabelCell'; import { RecordingLabelsPanel } from './RecordingLabelsPanel'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; -import { RecordingFiltersCategories } from './RecordingFilters'; -import { FilterDeleteOptions, filterRecordings, RecordingFilters } from './RecordingFilters'; +import { emptyArchivedRecordingFilters, RecordingFiltersCategories } from './RecordingFilters'; +import { filterRecordings, RecordingFilters } from './RecordingFilters'; import { ArchiveUploadModal } from '@app/Archives/ArchiveUploadModal'; +import { useDispatch, useSelector } from 'react-redux'; +import { TargetRecordingFilters } from '@app/Shared/Redux/RecordingFilterReducer'; +import { addFilterIntent, addTargetIntent, deleteAllFiltersIntent, deleteCategoryFiltersIntent, deleteFilterIntent } from '@app/Shared/Redux/RecordingFilterActions'; export interface ArchivedRecordingsTableProps { target: Observable; @@ -64,9 +67,13 @@ export interface ArchivedRecordingsTableProps { isNestedTable: boolean; } + export const ArchivedRecordingsTable: React.FunctionComponent = (props) => { const context = React.useContext(ServiceContext); - + const addSubscription = useSubscriptions(); + const disPatch = useDispatch(); + + const [target, setTarget] = React.useState(""); // connectURL of the target const [recordings, setRecordings] = React.useState([] as ArchivedRecording[]); const [filteredRecordings, setFilteredRecordings] = React.useState([] as ArchivedRecording[]); const [headerChecked, setHeaderChecked] = React.useState(false); @@ -75,13 +82,12 @@ export const ArchivedRecordingsTable: React.FunctionComponent { + const filters = state.recordingFilters.list.filter((targetFilter: TargetRecordingFilters) => targetFilter.target === target); + return filters.length > 0? filters[0].archived.filters: emptyArchivedRecordingFilters; + }) as RecordingFiltersCategories; const tableColumns: string[] = [ 'Name', @@ -111,7 +117,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { + const queryTargetRecordings = React.useCallback((connectUrl: string) => { return context.api.graphql(` query { archivedRecordings(filter: { sourceTarget: "${connectUrl}" }) { @@ -123,9 +129,9 @@ export const ArchivedRecordingsTable: React.FunctionComponent { + const queryUploadedRecordings = React.useCallback(() => { return context.api.graphql(` query { archivedRecordings(filter: { sourceTarget: "uploads" }) { @@ -137,7 +143,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { setIsLoading(true); @@ -166,41 +172,30 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - setFilters({ - Name: [], - Labels: [], - } as RecordingFiltersCategories); - }, [setFilters]); + disPatch(deleteAllFiltersIntent(target)); + }, [disPatch, target]); - const updateFilters = React.useCallback(({filterValue, filterKey, deleted = false, deleteOptions}) => { - setCurrentFilterCategory(filterKey); - setFilters((old) => { - if (!old[filterKey]) return old; - - const oldFilterValues = old[filterKey] as any[]; - let newfilterValues: any[]; - if (deleted) { - if (deleteOptions && (deleteOptions as FilterDeleteOptions).all) { - newfilterValues = []; - } else { - newfilterValues = oldFilterValues.filter((val) => val !== filterValue); - } + const updateFilters = React.useCallback((target, {filterValue, filterKey, deleted = false, deleteOptions}) => { + if (deleted) { + if (deleteOptions && deleteOptions.all) { + disPatch(deleteCategoryFiltersIntent(target, filterKey, true)); } else { - newfilterValues = Array.from(new Set([...oldFilterValues, filterValue])); + disPatch(deleteFilterIntent(target, filterKey, filterValue, true)); } - - const newFilters = {...old}; - newFilters[filterKey] = newfilterValues; - return newFilters; - }); - - }, [setCurrentFilterCategory, setFilters]); + } else { + disPatch(addFilterIntent(target, filterKey, filterValue, true)); + } + }, [disPatch]); React.useEffect(() => { addSubscription( - props.target.subscribe(refreshRecordingList) + props.target.subscribe((target) => { + setTarget(target.connectUrl); + disPatch(addTargetIntent(target.connectUrl)); + refreshRecordingList(); + }) ); - }, [addSubscription, refreshRecordingList]); + }, [addSubscription, refreshRecordingList, disPatch, setTarget]); React.useEffect(() => { addSubscription( @@ -294,8 +289,8 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - setFilteredRecordings(filterRecordings(recordings, filters)); - }, [recordings, filters]); + setFilteredRecordings(filterRecordings(recordings, targetRecordingFilters)); + }, [recordings, targetRecordingFilters]); React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { @@ -348,7 +343,8 @@ export const ArchivedRecordingsTable: React.FunctionComponent - ); - }, [props.recording, props.recording.metadata.labels, props.recording.name, props.index, handleCheck, checkedIndices, isExpanded, handleToggle, tableColumns, context.api]); + }, [props.recording, props.recording.metadata.labels, props.recording.name, props.index, handleCheck, checkedIndices, isExpanded, handleToggle, tableColumns, context.api, target]); const childRow = React.useMemo(() => { return ( @@ -413,7 +409,12 @@ export const ArchivedRecordingsTable: React.FunctionComponent - + @@ -438,7 +439,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - return filteredRecordings.map((r, idx) => ) + return filteredRecordings.map((r, idx) => ) }, [filteredRecordings, expandedRows, checkedIndices]); const handleModalClose = React.useCallback(() => { diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index 59cdf98b4..bdf6bcd86 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -56,6 +56,10 @@ import { LabelFilter } from './Filters/LabelFilter'; import { NameFilter } from './Filters/NameFilter'; import { RecordingStateFilter } from './Filters/RecordingStateFilter'; import { RecordingState } from '@app/Shared/Services/Api.service'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; +import { WritableDraft } from 'immer/dist/internal'; +import { TargetRecordingFilters, UpdateFilterOptions } from '@app/Shared/Redux/RecordingFilterReducer'; +import { updateCategoryIntent } from '@app/Shared/Redux/RecordingFilterActions'; export interface RecordingFiltersCategories { Name: string[], @@ -66,26 +70,38 @@ export interface RecordingFiltersCategories { DurationSeconds?: string[], } +export const emptyActiveRecordingFilters = { + Name: [], + Labels: [], + State: [], + StartedBeforeDate: [], + StartedAfterDate: [], + DurationSeconds: [], +} as RecordingFiltersCategories; + + +export const emptyArchivedRecordingFilters = { + Name: [], + Labels: [], +} as RecordingFiltersCategories; + export interface RecordingFiltersProps { - category: string, + target: string, + isArchived: boolean, recordings: ArchivedRecording[]; filters: RecordingFiltersCategories; - updateFilters: (updateFilterOptions: UpdateFilterOptions) => void; + updateFilters: (target: string, updateFilterOptions: UpdateFilterOptions) => void; } -export interface UpdateFilterOptions { - filterKey: string; - filterValue?: any; - deleted?: boolean; - deleteOptions?: FilterDeleteOptions -} +export const RecordingFilters: React.FunctionComponent = (props) => { + const disPatch = useDispatch(); -export interface FilterDeleteOptions { - all: boolean -} + const currentCategory = useSelector((state: any) => { + const targetRecordingFilters = state.recordingFilters.list.filter((targetFilter) => targetFilter.target === props.target); + if (!targetRecordingFilters.length) return "Name"; // Target is not yet loaded + return (props.isArchived? targetRecordingFilters[0].archived: targetRecordingFilters[0].active).selectedCategory; + }); -export const RecordingFilters: React.FunctionComponent = (props) => { - const [currentCategory, setCurrentCategory] = React.useState(props.category); const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = React.useState(false); const onCategoryToggle = React.useCallback(() => { @@ -93,55 +109,55 @@ export const RecordingFilters: React.FunctionComponent = }, [setIsCategoryDropdownOpen]); const onCategorySelect = React.useCallback( - (curr) => { - setCurrentCategory(curr); + (category) => { setIsCategoryDropdownOpen(false); - },[setCurrentCategory, setIsCategoryDropdownOpen] + disPatch(updateCategoryIntent(props.target, category, props.isArchived)) + },[disPatch, setIsCategoryDropdownOpen, props.target, props.isArchived] ); const onDelete = React.useCallback( - (category, value) => props.updateFilters({ filterKey: category, filterValue: value, deleted: true}), - [props.updateFilters] + (category, value) => props.updateFilters(props.target, { filterKey: category, filterValue: value, deleted: true }), + [props.updateFilters, props.target] ); const onDeleteGroup = React.useCallback( - (category) => props.updateFilters({ filterKey: category, deleted: true, deleteOptions: { all: true }}), - [props.updateFilters] + (category) => props.updateFilters(props.target, { filterKey: category, deleted: true, deleteOptions: {all: true} }), + [props.updateFilters, props.target] ); const onNameInput = React.useCallback( - (inputName) => props.updateFilters({ filterKey: currentCategory, filterValue: inputName }), - [props.updateFilters, currentCategory] + (inputName) => props.updateFilters(props.target, { filterKey: currentCategory!, filterValue: inputName }), + [props.updateFilters, currentCategory, props.target] ); const onLabelInput = React.useCallback( - (inputLabel) => props.updateFilters({ filterKey: currentCategory, filterValue: inputLabel }), - [props.updateFilters, currentCategory] + (inputLabel) => props.updateFilters(props.target, { filterKey: currentCategory!, filterValue: inputLabel }), + [props.updateFilters, currentCategory, props.target] ); const onStartedBeforeInput = React.useCallback( - (searchDate) => props.updateFilters({ filterKey: currentCategory, filterValue: searchDate}), - [props.updateFilters, currentCategory] + (searchDate) => props.updateFilters(props.target, { filterKey: currentCategory!, filterValue: searchDate }), + [props.updateFilters, currentCategory, props.target] ); const onStartedAfterInput = React.useCallback( - (searchDate) => props.updateFilters({ filterKey: currentCategory, filterValue: searchDate}), - [props.updateFilters, currentCategory] + (searchDate) => props.updateFilters(props.target, { filterKey: currentCategory!, filterValue: searchDate }), + [props.updateFilters, currentCategory, props.target] ); const onDurationInput = React.useCallback( - (duration) => props.updateFilters({ filterKey: currentCategory, filterValue: `${duration.toString()} s` }), - [props.updateFilters, currentCategory] + (duration) => props.updateFilters(props.target,{ filterKey: currentCategory!, filterValue: `${duration.toString()} s` }), + [props.updateFilters, currentCategory, props.target] ); const onRecordingStateSelect = React.useCallback( - (searchState) => props.updateFilters({ filterKey: currentCategory, filterValue: searchState }), - [props.updateFilters, currentCategory] + (searchState) => props.updateFilters(props.target,{ filterKey: currentCategory!, filterValue: searchState }), + [props.updateFilters, currentCategory, props.target] ); const onContinuousDurationSelect = React.useCallback( - (cont) => props.updateFilters({ filterKey: currentCategory, filterValue: 'continuous', deleted: !cont }), - [props.updateFilters, currentCategory] + (cont) => props.updateFilters(props.target,{ filterKey: currentCategory!, filterValue: 'continuous', deleted: !cont }), + [props.updateFilters, currentCategory, props.target] ); const categoryDropdown = React.useMemo(() => { @@ -212,7 +228,7 @@ export const RecordingFilters: React.FunctionComponent = ); }; -export const filterRecordings = (recordings, filters) => { +export const filterRecordings = (recordings: any[], filters: RecordingFiltersCategories) => { if (!recordings || !recordings.length) { return recordings; } diff --git a/src/app/index.tsx b/src/app/index.tsx index b1eae4860..1bd8473fb 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -42,14 +42,18 @@ import { AppLayout } from '@app/AppLayout/AppLayout'; import { AppRoutes } from '@app/routes'; import '@app/app.css'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; +import { Provider } from 'react-redux'; +import { store } from '@app/Shared/Redux/ReduxStore'; const App: React.FunctionComponent = () => ( - - - - - + + + + + + + ); diff --git a/src/test/RecordingMetadata.tsx/LabelCell.test.tsx b/src/test/RecordingMetadata.tsx/LabelCell.test.tsx index 33a7083b1..f1a49b20d 100644 --- a/src/test/RecordingMetadata.tsx/LabelCell.test.tsx +++ b/src/test/RecordingMetadata.tsx/LabelCell.test.tsx @@ -51,13 +51,13 @@ describe('', () => { it('renders correctly', async () => { let tree; await act(async () => { - tree = renderer.create(); + tree = renderer.create(); }); expect(tree.toJSON()).toMatchSnapshot(); }); it('displays read-only labels', () => { - render(); + render(); expect(screen.getByText('someLabel: someValue')).toBeInTheDocument(); expect(screen.queryByText('Add Label')).not.toBeInTheDocument(); }); From 42a9aa683a1cba7f455f2f1475bbd5bcc99aeaca Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Sun, 11 Sep 2022 20:51:09 -0400 Subject: [PATCH 28/98] fix(recording-tables): add missing deps for react callbacks and memos --- src/app/Recordings/ActiveRecordingsTable.tsx | 13 +++--- .../Recordings/ArchivedRecordingsTable.tsx | 42 ++++++++++++++----- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index e896d8ed8..efe2d5dc5 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -120,7 +120,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { setShowDetailsPanel(true); setPanelContent(PanelContent.LABELS); - }, [setShowDetailsPanel]); + }, [setShowDetailsPanel, setPanelContent]); const handleRecordings = React.useCallback((recordings) => { setRecordings(recordings); @@ -235,11 +235,10 @@ export const ActiveRecordingsTable: React.FunctionComponent { - const sub = context.target.authFailure().subscribe(() => { + addSubscription(context.target.authFailure().subscribe(() => { setErrorMessage("Auth failure"); - }); - return () => sub.unsubscribe(); - }, [context, context.target, setErrorMessage]); + })); + }, [context, context.target, setErrorMessage, addSubscription]); React.useEffect(() => { addSubscription( @@ -326,7 +325,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { setFilteredRecordings(filterRecordings(recordings, targetRecordingFilters)); - }, [recordings, targetRecordingFilters]); + }, [recordings, targetRecordingFilters, setFilteredRecordings, filterRecordings]); React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { @@ -483,7 +482,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { setWarningModalOpen(false); diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index 1e8151592..309dd5f71 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -283,14 +283,18 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - const idx = expandedRows.indexOf(id); - setExpandedRows(expandedRows => idx >= 0 ? [...expandedRows.slice(0, idx), ...expandedRows.slice(idx + 1, expandedRows.length)] : [...expandedRows, id]); - }; + const toggleExpanded = React.useCallback( + (id: string) => { + setExpandedRows((expandedRows) => { + const idx = expandedRows.indexOf(id); + return idx >= 0 ? [...expandedRows.slice(0, idx), ...expandedRows.slice(idx + 1, expandedRows.length)] : [...expandedRows, id] + }); + }, [setExpandedRows] + ); React.useEffect(() => { setFilteredRecordings(filterRecordings(recordings, targetRecordingFilters)); - }, [recordings, targetRecordingFilters]); + }, [recordings, targetRecordingFilters, setFilteredRecordings, filterRecordings]); React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { @@ -305,18 +309,18 @@ export const ArchivedRecordingsTable: React.FunctionComponent { + const expandedRowId = React.useMemo(() => `archived-table-row-${props.index}-exp`, [props.index]); + const handleToggle = React.useCallback(() => { toggleExpanded(expandedRowId); - }; + }, [toggleExpanded, expandedRowId]); const isExpanded = React.useMemo(() => { return expandedRows.includes(expandedRowId); }, [expandedRows, expandedRowId]); - const handleCheck = (checked) => { + const handleCheck = React.useCallback((checked) => { handleRowCheck(checked, props.index); - }; + }, [handleRowCheck, props.index]); const parentRow = React.useMemo(() => { return( @@ -357,7 +361,23 @@ export const ArchivedRecordingsTable: React.FunctionComponent ); - }, [props.recording, props.recording.metadata.labels, props.recording.name, props.index, handleCheck, checkedIndices, isExpanded, handleToggle, tableColumns, context.api, target]); + }, [ + props.recording, + props.recording.metadata.labels, + props.recording.name, + props.index, + props.labelFilters, + checkedIndices, + isExpanded, + handleCheck, + handleToggle, + updateFilters, + tableColumns, + parsedLabels, + context.api, + context.api.uploadArchivedRecordingToGrafana, + target + ]); const childRow = React.useMemo(() => { return ( From 59d0a90ba645cd1e3ce86be3fd9406c1076f694b Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Sun, 11 Sep 2022 21:31:07 -0400 Subject: [PATCH 29/98] fix(filters): fix clearAllFilters button --- src/app/Recordings/ActiveRecordingsTable.tsx | 2 +- .../Recordings/ArchivedRecordingsTable.tsx | 2 +- .../Shared/Redux/RecordingFilterActions.tsx | 3 ++- .../Shared/Redux/RecordingFilterReducer.tsx | 22 ++++++++++++++++++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index efe2d5dc5..8dad1f71b 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -308,7 +308,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { - disPatch(deleteAllFiltersIntent(target)); + disPatch(deleteAllFiltersIntent(target, false)); }, [disPatch, target]); const updateFilters = React.useCallback((target, {filterValue, filterKey, deleted = false, deleteOptions}) => { diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index 309dd5f71..9dd1cc3b1 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -172,7 +172,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - disPatch(deleteAllFiltersIntent(target)); + disPatch(deleteAllFiltersIntent(target, true)); }, [disPatch, target]); const updateFilters = React.useCallback((target, {filterValue, filterKey, deleted = false, deleteOptions}) => { diff --git a/src/app/Shared/Redux/RecordingFilterActions.tsx b/src/app/Shared/Redux/RecordingFilterActions.tsx index 4edb116ff..e6708a34a 100644 --- a/src/app/Shared/Redux/RecordingFilterActions.tsx +++ b/src/app/Shared/Redux/RecordingFilterActions.tsx @@ -82,9 +82,10 @@ export const deleteCategoryFiltersIntent = createAction(RecordingFilterAction.CA } as RecordingFilterActionPayload })); -export const deleteAllFiltersIntent = createAction(RecordingFilterAction.FILTER_DELETE_ALL, (target: string) => ({ +export const deleteAllFiltersIntent = createAction(RecordingFilterAction.FILTER_DELETE_ALL, (target: string, isArchived: boolean) => ({ payload: { target: target, + isArchived: isArchived } as RecordingFilterActionPayload })); diff --git a/src/app/Shared/Redux/RecordingFilterReducer.tsx b/src/app/Shared/Redux/RecordingFilterReducer.tsx index 3a796b112..5bb7cf300 100644 --- a/src/app/Shared/Redux/RecordingFilterReducer.tsx +++ b/src/app/Shared/Redux/RecordingFilterReducer.tsx @@ -106,6 +106,25 @@ export const createEmptyTargetRecordingFilters = (target: string) => ( } as TargetRecordingFilters ); +export const deleteAllTargetRecordingFilters = (targetRecordingFilter: TargetRecordingFilters, isArchived: boolean) => { + if (isArchived) { + return { + ...targetRecordingFilter, + archived: { + selectedCategory: targetRecordingFilter.archived.selectedCategory, + filters: emptyArchivedRecordingFilters, + } + } + } + return { + ...targetRecordingFilter, + active: { + selectedCategory: targetRecordingFilter.active.selectedCategory, + filters: emptyActiveRecordingFilters, + } + } +} + // Initial states are loaded from local storage if there are any (TODO) const initialState = { list: [] as TargetRecordingFilters[] }; @@ -203,7 +222,8 @@ export const recordingFilterReducer = createReducer(initialState, (builder) => { state.list.push(newTargetRecordingFilter); }) .addCase(deleteAllFiltersIntent, (state, {payload}) => { - const newTargetRecordingFilter = createEmptyTargetRecordingFilters(payload.target); + const oldTargetRecordingFilter = getTargetRecordingFilter(state, payload.target); + const newTargetRecordingFilter = deleteAllTargetRecordingFilters(oldTargetRecordingFilter, payload.isArchived!); state.list = state.list.filter((targetFilters) => targetFilters.target !== newTargetRecordingFilter.target); state.list.push(newTargetRecordingFilter); }) From a4a5c1d0f08b675a25f6c4c2d1dd6de2981d0e41 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Sun, 11 Sep 2022 22:55:36 -0400 Subject: [PATCH 30/98] feat(labels): clickable labels should be highlighted on hover --- src/app/RecordingMetadata/LabelCell.tsx | 74 ++++++++++++++++++++----- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/src/app/RecordingMetadata/LabelCell.tsx b/src/app/RecordingMetadata/LabelCell.tsx index 4fef5bdf5..c62dd27b2 100644 --- a/src/app/RecordingMetadata/LabelCell.tsx +++ b/src/app/RecordingMetadata/LabelCell.tsx @@ -52,36 +52,84 @@ export interface LabelCellProps { } export const LabelCell: React.FunctionComponent = (props) => { - const labelStyle = React.useMemo(() => (props.updateFilters? { - cursor: "pointer", - }: {}), - [props.updateFilters]); - const labelFilterSet = React.useMemo(() => new Set(props.labelFilters), [props.labelFilters]); + const isLabelSelected = React.useCallback((label: RecordingLabel) => labelFilterSet.has(getLabelDisplay(label)), [labelFilterSet, getLabelDisplay]); + const getLabelColor = React.useCallback((label: RecordingLabel) => isLabelSelected(label)? "blue": "grey", [isLabelSelected]); + const onLabelSelectToggle = React.useCallback( - (selectedLabel) => { + (clickedLabel: RecordingLabel) => { if (props.updateFilters) { - props.updateFilters(props.target, {filterKey: "Labels", filterValue: selectedLabel, deleted: labelFilterSet.has(selectedLabel)}) + props.updateFilters(props.target, {filterKey: "Labels", filterValue: getLabelDisplay(clickedLabel), deleted: isLabelSelected(clickedLabel)}) } - }, - [props.updateFilters, props.labelFilters, props.target]); - + }, + [props.updateFilters, props.labelFilters, props.target, getLabelDisplay]); + return ( <> {!!props.labels && props.labels.length? ( props.labels.map((label) => + props.updateFilters? + : + )) : ( - )} ); }; + +export interface ClickableLabelCellProps { + label: RecordingLabel; + isSelected: boolean; + onLabelClick: (label: RecordingLabel) => void +} + +export const ClickableLabel: React.FunctionComponent = (props) => { + const [isHoveredOrFocused, setIsHoveredOrFocused] = React.useState(false); + const labelColor = React.useMemo(() => props.isSelected? "blue": "grey", [props.isSelected]); + + const handleHoveredOrFocused = React.useCallback(() => setIsHoveredOrFocused(true), [setIsHoveredOrFocused]); + const handleNonHoveredOrFocused = React.useCallback(() => setIsHoveredOrFocused(false), [setIsHoveredOrFocused]); + + const style = React.useMemo(() => { + if (isHoveredOrFocused) { + const defaultStyle = { cursor: "pointer", "--pf-c-label__content--before--BorderWidth": "2.5px"}; + if (props.isSelected) { + return {...defaultStyle, "--pf-c-label__content--before--BorderColor": "#06c"} + } + return {...defaultStyle, "--pf-c-label__content--before--BorderColor": "#8a8d90"} + } + return {}; + }, [props.isSelected, isHoveredOrFocused]); + + const handleLabelClicked = React.useCallback( + () => props.onLabelClick(props.label), + [props.label, props.onLabelClick, getLabelDisplay] + ); + + return <> + + ; +} From c8d41e82b5ada7f659b4a7ecee970a2f9f6dfd1e Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Sun, 11 Sep 2022 23:01:13 -0400 Subject: [PATCH 31/98] fix(applayout): add unique keys for each child component in list --- src/app/AppLayout/AppLayout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/AppLayout/AppLayout.tsx b/src/app/AppLayout/AppLayout.tsx index 1a0869013..9f28a40ef 100644 --- a/src/app/AppLayout/AppLayout.tsx +++ b/src/app/AppLayout/AppLayout.tsx @@ -253,7 +253,7 @@ const AppLayout: React.FunctionComponent = ({children}) => { {navGroups.map((title) => { return ( - + {routes.filter(route => route.navGroup === title) .map((route, idx) => { return ( @@ -295,6 +295,7 @@ const AppLayout: React.FunctionComponent = ({children}) => { .map(( { key, title, message, variant } ) => ( handleMarkNotificationRead(key)} />} timeout={true} From f1442afb94f3214620cf501e14b1360630bc3e1a Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Sun, 11 Sep 2022 23:40:44 -0400 Subject: [PATCH 32/98] feat(filters): filter states should be saved to local storage --- src/app/Shared/Redux/RecordingFilterReducer.tsx | 3 ++- src/app/Shared/Redux/ReduxStore.tsx | 5 +++++ src/app/utils/LocalStorage.ts | 3 +-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/Shared/Redux/RecordingFilterReducer.tsx b/src/app/Shared/Redux/RecordingFilterReducer.tsx index 5bb7cf300..22d8781ea 100644 --- a/src/app/Shared/Redux/RecordingFilterReducer.tsx +++ b/src/app/Shared/Redux/RecordingFilterReducer.tsx @@ -37,6 +37,7 @@ */ import { emptyActiveRecordingFilters, emptyArchivedRecordingFilters, RecordingFiltersCategories } from "@app/Recordings/RecordingFilters" +import { getFromLocalStorage } from "@app/utils/LocalStorage"; import { createReducer } from "@reduxjs/toolkit" import { WritableDraft } from "immer/dist/internal"; import { addFilterIntent, addTargetIntent, deleteAllFiltersIntent, deleteCategoryFiltersIntent, deleteFilterIntent, deleteTargetIntent, updateCategoryIntent, } from './RecordingFilterActions'; @@ -126,7 +127,7 @@ export const deleteAllTargetRecordingFilters = (targetRecordingFilter: TargetRec } // Initial states are loaded from local storage if there are any (TODO) -const initialState = { list: [] as TargetRecordingFilters[] }; +const initialState = { list: getFromLocalStorage("TARGET_RECORDING_FILTERS", [])}; export const recordingFilterReducer = createReducer(initialState, (builder) => { builder diff --git a/src/app/Shared/Redux/ReduxStore.tsx b/src/app/Shared/Redux/ReduxStore.tsx index 3c909e3bb..aaf4eb9af 100644 --- a/src/app/Shared/Redux/ReduxStore.tsx +++ b/src/app/Shared/Redux/ReduxStore.tsx @@ -36,6 +36,7 @@ * SOFTWARE. */ +import { saveToLocalStorage } from "@app/utils/LocalStorage"; import { configureStore } from "@reduxjs/toolkit"; import { recordingFilterReducer as recordingFiltersReducer } from "./RecordingFilterReducer"; @@ -44,3 +45,7 @@ export const store = configureStore({ recordingFilters: recordingFiltersReducer } }); + +// Add a subscription to save filter states to local storage +// Every 500ms +store.subscribe(() => saveToLocalStorage("TARGET_RECORDING_FILTERS", store.getState().recordingFilters.list)); diff --git a/src/app/utils/LocalStorage.ts b/src/app/utils/LocalStorage.ts index a616f3aa5..6abd07a9a 100644 --- a/src/app/utils/LocalStorage.ts +++ b/src/app/utils/LocalStorage.ts @@ -37,8 +37,7 @@ */ export enum LocalStorageKey { - ACTIVE_RECORDING_FILTER, - ARCHIVED_RECORDING_FILTER + TARGET_RECORDING_FILTERS } /** From 07afc2ce89ba0591d73234972ca646b6f5f2dc60 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 12 Sep 2022 00:10:15 -0400 Subject: [PATCH 33/98] fix(filters): state filter now allows toggling --- .../Filters/RecordingStateFilter.tsx | 20 +++++++------------ src/app/Recordings/RecordingFilters.tsx | 9 ++++++--- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/app/Recordings/Filters/RecordingStateFilter.tsx b/src/app/Recordings/Filters/RecordingStateFilter.tsx index 115ddb692..b03b514a6 100644 --- a/src/app/Recordings/Filters/RecordingStateFilter.tsx +++ b/src/app/Recordings/Filters/RecordingStateFilter.tsx @@ -42,24 +42,18 @@ import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import { RecordingState } from '@app/Shared/Services/Api.service'; export interface RecordingStateFilterProps { - states: RecordingState[] | undefined; - onSubmit: (state: any) => void; + filteredStates: RecordingState[] | undefined; + onSelectToggle: (state: any) => void; } export const RecordingStateFilter: React.FunctionComponent = (props) => { const [isOpen, setIsOpen] = React.useState(false); - const [selected, setSelected] = React.useState(new Set(props.states)); const onSelect = React.useCallback( - (event, selection, isPlaceholder) => { - if (isPlaceholder) { - setSelected(new Set()); - setIsOpen(false); - } else { - setSelected((selected) => selected.add(selection)); - props.onSubmit(selection); - } - }, [setSelected, setIsOpen, props.onSubmit]); + (_, selection) => { + setIsOpen(false); + props.onSelectToggle(selection); + }, [setIsOpen, props.onSelectToggle]); return ( + + + + +`; From 2136672d4994c38568da3aa0d712e132545eda4e Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 12 Sep 2022 13:47:23 -0400 Subject: [PATCH 38/98] fix(filter): header check should update when filter changes --- src/app/Recordings/ActiveRecordingsTable.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 92debe61e..72da2b9d2 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -260,6 +260,12 @@ export const ActiveRecordingsTable: React.FunctionComponent { + if (checkedIndices.length < filteredRecordings.length) { + setHeaderChecked(false); + } + }, [setHeaderChecked, checkedIndices, filteredRecordings]) + const handleArchiveRecordings = React.useCallback(() => { const tasks: Observable[] = []; filteredRecordings.forEach((r: ActiveRecording, idx) => { From ff2adf0540683023825772060b5af29b61568c01 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 12 Sep 2022 14:47:00 -0400 Subject: [PATCH 39/98] fix(filters): labels in filter should be sorted --- src/app/Recordings/Filters/LabelFilter.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/app/Recordings/Filters/LabelFilter.tsx b/src/app/Recordings/Filters/LabelFilter.tsx index 9e66bc5ba..103b859f8 100644 --- a/src/app/Recordings/Filters/LabelFilter.tsx +++ b/src/app/Recordings/Filters/LabelFilter.tsx @@ -51,7 +51,6 @@ export const getLabelDisplay = (label: RecordingLabel) => `${label.key}:${label. export const LabelFilter: React.FunctionComponent = (props) => { const [isOpen, setIsOpen] = React.useState(false); const [selected, setSelected] = React.useState(''); - const [labels, setLabels] = React.useState([] as string[]); const onSelect = React.useCallback( (event, selection, isPlaceholder) => { @@ -66,16 +65,14 @@ export const LabelFilter: React.FunctionComponent = (props) => [props.onSubmit, setIsOpen, setSelected] ); - React.useEffect(() => { - setLabels(old => { - let updated = new Set(old); - props.recordings.forEach((r) => { - if (!r || !r.metadata) return; - parseLabels(r.metadata.labels).map((label) => updated.add(getLabelDisplay(label))); - }); - return Array.from(updated); - }); - }, [setLabels, props.recordings]); + const labels = React.useMemo(() => { + let labels = new Set(); + props.recordings.forEach((r) => { + if (!r || !r.metadata) return; + parseLabels(r.metadata.labels).map((label) => labels.add(getLabelDisplay(label))); + }); + return Array.from(labels).sort(); + }, [props.recordings, parseLabels, getLabelDisplay]); return ( Date: Mon, 12 Sep 2022 16:47:37 -0400 Subject: [PATCH 41/98] fix(active-recording-table): header and checked indices now update properly --- src/app/Recordings/ActiveRecordingsTable.tsx | 81 +++++++++----------- 1 file changed, 37 insertions(+), 44 deletions(-) diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 72da2b9d2..7f8bdb57a 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -110,7 +110,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { setHeaderChecked(checked); - setCheckedIndices(checked ? Array.from(new Array(filteredRecordings.length), (x, i) => i) : []); + setCheckedIndices(checked ? filteredRecordings.map((r) => r.id) : []); }, [setHeaderChecked, setCheckedIndices, filteredRecordings]); const handleCreateRecording = React.useCallback(() => { @@ -190,21 +190,9 @@ export const ActiveRecordingsTable: React.FunctionComponent { - return old.filter((r, i) => { - if (r.name == event.message.recording.name) { - deleted = i; - return false; - } - return true; - }); - }); - setCheckedIndices(old => old - .filter((v) => v !== deleted) - .map(ci => ci > deleted ? ci - 1 : ci) - ); + setRecordings((old) => old.filter((r) => r.name !== event.message.recording.name)); + setCheckedIndices(old => old.filter((idx) => idx !== event.message.recording.id)); }) ); }, [addSubscription, context, context.notificationChannel, setRecordings, setCheckedIndices]); @@ -261,16 +249,33 @@ export const ActiveRecordingsTable: React.FunctionComponent { - if (checkedIndices.length < filteredRecordings.length) { - setHeaderChecked(false); + setFilteredRecordings(filterRecordings(recordings, targetRecordingFilters)); + }, [recordings, targetRecordingFilters, setFilteredRecordings, filterRecordings]); + + React.useEffect(() => { + setCheckedIndices((ci) => { + const filteredRecordingIdx = new Set(filteredRecordings.map((r) => r.id)); + return ci.filter((idx) => filteredRecordingIdx.has(idx)); + }); + }, [filteredRecordings, setCheckedIndices]); + + React.useEffect(() => { + setHeaderChecked(checkedIndices.length === filteredRecordings.length); + }, [setHeaderChecked, checkedIndices]); + + React.useEffect(() => { + if (!context.settings.autoRefreshEnabled()) { + return; } - }, [setHeaderChecked, checkedIndices, filteredRecordings]) + const id = window.setInterval(() => refreshRecordingList(), context.settings.autoRefreshPeriod() * context.settings.autoRefreshUnits()); + return () => window.clearInterval(id); + }, [refreshRecordingList, context, context.settings]); const handleArchiveRecordings = React.useCallback(() => { const tasks: Observable[] = []; - filteredRecordings.forEach((r: ActiveRecording, idx) => { - if (checkedIndices.includes(idx)) { - handleRowCheck(false, idx); + filteredRecordings.forEach((r: ActiveRecording) => { + if (checkedIndices.includes(r.id)) { + handleRowCheck(false, r.id); tasks.push( context.api.archiveRecording(r.name).pipe(first()) ); @@ -283,9 +288,9 @@ export const ActiveRecordingsTable: React.FunctionComponent { const tasks: Observable[] = []; - filteredRecordings.forEach((r: ActiveRecording, idx) => { - if (checkedIndices.includes(idx)) { - handleRowCheck(false, idx); + filteredRecordings.forEach((r: ActiveRecording) => { + if (checkedIndices.includes(r.id)) { + handleRowCheck(false, r.id); if (r.state === RecordingState.RUNNING || r.state === RecordingState.STARTING) { tasks.push( context.api.stopRecording(r.name).pipe(first()) @@ -300,8 +305,8 @@ export const ActiveRecordingsTable: React.FunctionComponent { const tasks: Observable<{}>[] = []; - filteredRecordings.forEach((r: ActiveRecording, idx) => { - if (checkedIndices.includes(idx)) { + filteredRecordings.forEach((r: ActiveRecording) => { + if (checkedIndices.includes(r.id)) { context.reports.delete(r); tasks.push( context.api.deleteRecording(r.name).pipe(first()) @@ -329,18 +334,6 @@ export const ActiveRecordingsTable: React.FunctionComponent { - setFilteredRecordings(filterRecordings(recordings, targetRecordingFilters)); - }, [recordings, targetRecordingFilters, setFilteredRecordings, filterRecordings]); - - React.useEffect(() => { - if (!context.settings.autoRefreshEnabled()) { - return; - } - const id = window.setInterval(() => refreshRecordingList(), context.settings.autoRefreshPeriod() * context.settings.autoRefreshUnits()); - return () => window.clearInterval(id); - }, [refreshRecordingList, context, context.settings]); - const RecordingRow = (props) => { const parsedLabels = React.useMemo(() => { return parseLabels(props.recording.metadata.labels); @@ -499,7 +492,7 @@ export const ActiveRecordingsTable: React.FunctionComponent checkedIndices.includes(idx)); + const filtered = filteredRecordings.filter((r: ActiveRecording) => checkedIndices.includes(r.id)); const anyRunning = filtered.some((r: ActiveRecording) => r.state === RecordingState.RUNNING || r.state == RecordingState.STARTING); return !anyRunning; }, [checkedIndices, filteredRecordings]); @@ -528,7 +521,7 @@ export const ActiveRecordingsTable: React.FunctionComponent; - }, [checkedIndices]); + }, [checkedIndices, handleCreateRecording, handleArchiveRecordings, handleEditLabels, handleStopRecordings, handleDeleteButton]); const deleteActiveWarningModal = React.useMemo(() => { return - }, [recordings, checkedIndices]); + }, [recordings, checkedIndices, handleDeleteRecordings, handleWarningModalClose]); return ( @@ -558,8 +551,8 @@ export const ActiveRecordingsTable: React.FunctionComponent { - return filteredRecordings.map((r, idx) => ) - }, [filteredRecordings, expandedRows, checkedIndices]); + return filteredRecordings.map((r) => ) + }, [filteredRecordings, expandedRows, targetRecordingFilters, checkedIndices]); const LabelsPanel = React.useMemo(() => ( - ), [checkedIndices]); + ), [checkedIndices, setShowDetailsPanel]); return ( From 772bd6cef2c94ad6cdd2c454981e8613ab687723 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 12 Sep 2022 17:25:02 -0400 Subject: [PATCH 42/98] fix(archived-recording-table): header and checked indices now update properly --- .../Recordings/ArchivedRecordingsTable.tsx | 73 ++++++++++--------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index e8c93dc7c..1c5781819 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -96,7 +96,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { setHeaderChecked(checked); - setCheckedIndices(checked ? Array.from(new Array(filteredRecordings.length), (x, i) => i) : []); + setCheckedIndices(checked ? filteredRecordings.map((r) => transformNameToNumber(r.name)) : []); }, [setHeaderChecked, setCheckedIndices, filteredRecordings]); const handleRowCheck = React.useCallback((checked, index) => { @@ -229,21 +229,9 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - return old.filter((r, i) => { - if (r.name == event.message.recording.name) { - deleted = i; - return false; - } - return true; - }); - }); - setCheckedIndices(old => old - .filter((v) => v !== deleted) - .map(ci => ci > deleted ? ci - 1 : ci) - ); + + setRecordings((old) => old.filter((r) => r.name !== event.message.recording.name)); + setCheckedIndices(old => old.filter((idx) => idx !== transformNameToNumber(event.message.recording.name))); }) ); }, [addSubscription, context, context.notificationChannel, setRecordings, setCheckedIndices]); @@ -268,10 +256,33 @@ export const ArchivedRecordingsTable: React.FunctionComponent { + setFilteredRecordings(filterRecordings(recordings, targetRecordingFilters)); + }, [recordings, targetRecordingFilters, setFilteredRecordings, filterRecordings]); + + React.useEffect(() => { + if (!context.settings.autoRefreshEnabled()) { + return; + } + const id = window.setInterval(() => refreshRecordingList(), context.settings.autoRefreshPeriod() * context.settings.autoRefreshUnits()); + return () => window.clearInterval(id); + }, [context, context.settings, refreshRecordingList]); + + React.useEffect(() => { + setCheckedIndices((ci) => { + const filteredRecordingIdx = new Set(filteredRecordings.map((r) => transformNameToNumber(r.name))); + return ci.filter((idx) => filteredRecordingIdx.has(idx)); + }); + }, [filteredRecordings, setCheckedIndices]); + + React.useEffect(() => { + setHeaderChecked(checkedIndices.length === filteredRecordings.length); + }, [setHeaderChecked, checkedIndices]); + const handleDeleteRecordings = React.useCallback(() => { const tasks: Observable[] = []; - filteredRecordings.forEach((r: ArchivedRecording, idx) => { - if (checkedIndices.includes(idx)) { + filteredRecordings.forEach((r: ArchivedRecording) => { + if (checkedIndices.includes(transformNameToNumber(r.name))) { context.reports.delete(r); tasks.push( context.api.deleteArchivedRecording(r.name).pipe(first()) @@ -292,18 +303,6 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - setFilteredRecordings(filterRecordings(recordings, targetRecordingFilters)); - }, [recordings, targetRecordingFilters, setFilteredRecordings, filterRecordings]); - - React.useEffect(() => { - if (!context.settings.autoRefreshEnabled()) { - return; - } - const id = window.setInterval(() => refreshRecordingList(), context.settings.autoRefreshPeriod() * context.settings.autoRefreshUnits()); - return () => window.clearInterval(id); - }, [context, context.settings, refreshRecordingList]); - const RecordingRow = (props) => { const parsedLabels = React.useMemo(() => { return parseLabels(props.recording.metadata.labels); @@ -410,7 +409,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { setWarningModalOpen(false); @@ -424,7 +423,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent - }, [recordings, checkedIndices]); + }, [warningModalOpen, recordings, checkedIndices, handleDeleteRecordings, handleWarningModalClose]); return ( @@ -459,7 +458,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - return filteredRecordings.map((r, idx) => ) + return filteredRecordings.map((r) => ) }, [filteredRecordings, expandedRows, checkedIndices]); const handleModalClose = React.useCallback(() => { @@ -473,7 +472,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent - ), [checkedIndices]); + ), [checkedIndices, setShowDetailsPanel]); return ( @@ -505,3 +504,9 @@ export const ArchivedRecordingsTable: React.FunctionComponent ); }; + + +export const transformNameToNumber = (recordingName: string) => { + const utf8Array = new TextEncoder().encode(recordingName); + return utf8Array.reduce((prev, curr) => prev + curr, 0); +} From 3dd935d06b25d77272ae8ba66af5e895c11e185a Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 12 Sep 2022 17:45:34 -0400 Subject: [PATCH 43/98] fix(bulk-edit): bulk-edit must choose correctly selected rows --- src/app/RecordingMetadata/BulkEditLabels.tsx | 19 ++++++++++++++++--- src/app/RecordingMetadata/RecordingLabel.tsx | 4 ++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/app/RecordingMetadata/BulkEditLabels.tsx b/src/app/RecordingMetadata/BulkEditLabels.tsx index ea0eb9e50..f566b70bf 100644 --- a/src/app/RecordingMetadata/BulkEditLabels.tsx +++ b/src/app/RecordingMetadata/BulkEditLabels.tsx @@ -39,7 +39,7 @@ import * as React from 'react'; import { Button, Split, SplitItem, Stack, StackItem, Text, Tooltip, ValidatedOptions } from '@patternfly/react-core'; import { ServiceContext } from '@app/Shared/Services/Services'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { ActiveRecording, ArchivedRecording } from '@app/Shared/Services/Api.service'; +import { ActiveRecording, ArchivedRecording, isActiveRecording } from '@app/Shared/Services/Api.service'; import { includesLabel, parseLabels, RecordingLabel } from './RecordingLabel'; import { combineLatest, concatMap, filter, first, forkJoin, map, merge, Observable } from 'rxjs'; import { LabelCell } from '@app/RecordingMetadata/LabelCell'; @@ -47,6 +47,7 @@ import { RecordingLabelFields } from './RecordingLabelFields'; import { HelpIcon } from '@patternfly/react-icons'; import { NO_TARGET } from '@app/Shared/Services/Target.service'; import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { transformNameToNumber } from '@app/Recordings/ArchivedRecordingsTable'; export interface BulkEditLabelsProps { isTargetRecording: boolean; @@ -62,11 +63,21 @@ export const BulkEditLabels: React.FunctionComponent = (pro const [valid, setValid] = React.useState(ValidatedOptions.default); const addSubscription = useSubscriptions(); + console.log(props.checkedIndices); + const getIdxFromRecording = React.useCallback((r: ArchivedRecording): number => { + if (isActiveRecording(r)) { + return r.id; + } else { + return transformNameToNumber(r.name); + } + }, [isActiveRecording, transformNameToNumber]); + const handleUpdateLabels = React.useCallback(() => { const tasks: Observable[] = []; const toDelete = savedCommonLabels.filter((label) => !includesLabel(commonLabels, label)); - recordings.forEach((r: ArchivedRecording, idx) => { + recordings.forEach((r: ArchivedRecording) => { + const idx = getIdxFromRecording(r); if (props.checkedIndices.includes(idx)) { let updatedLabels = [...parseLabels(r.metadata.labels), ...commonLabels]; updatedLabels = updatedLabels.filter((label) => { @@ -106,7 +117,9 @@ export const BulkEditLabels: React.FunctionComponent = (pro const updateCommonLabels = React.useCallback( (setLabels: (l: RecordingLabel[]) => void) => { let allRecordingLabels = [] as RecordingLabel[][]; - recordings.forEach((r: ArchivedRecording, idx) => { + + recordings.forEach((r: ArchivedRecording) => { + const idx = getIdxFromRecording(r); if (props.checkedIndices.includes(idx)) { allRecordingLabels.push(parseLabels(r.metadata.labels)); } diff --git a/src/app/RecordingMetadata/RecordingLabel.tsx b/src/app/RecordingMetadata/RecordingLabel.tsx index 163a4d665..310279741 100644 --- a/src/app/RecordingMetadata/RecordingLabel.tsx +++ b/src/app/RecordingMetadata/RecordingLabel.tsx @@ -49,10 +49,10 @@ export const parseLabels = (jsonLabels) => { }); }; -export const includesLabel = (arr, searchLabel) => { +export const includesLabel = (arr: RecordingLabel[], searchLabel: RecordingLabel) => { return arr.some(l => isEqualLabel(searchLabel, l)); } -const isEqualLabel = (a, b) => { +const isEqualLabel = (a: RecordingLabel, b: RecordingLabel) => { return (a.key === b.key) && (a.value === b.value); } From 2a8ac40c537aefab870268585ca156c7392599ae Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 12 Sep 2022 17:49:56 -0400 Subject: [PATCH 44/98] fix(filters): name and label filters should excluded the selected options --- src/app/Recordings/Filters/LabelFilter.tsx | 7 ++++--- src/app/Recordings/Filters/NameFilter.tsx | 5 ++++- src/app/Recordings/RecordingFilters.tsx | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/Recordings/Filters/LabelFilter.tsx b/src/app/Recordings/Filters/LabelFilter.tsx index 103b859f8..573cab09d 100644 --- a/src/app/Recordings/Filters/LabelFilter.tsx +++ b/src/app/Recordings/Filters/LabelFilter.tsx @@ -42,8 +42,9 @@ import { ArchivedRecording } from '@app/Shared/Services/Api.service'; import { parseLabels, RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; export interface LabelFilterProps { - recordings: ArchivedRecording[]; - onSubmit: (inputLabel: string) => void; + recordings: ArchivedRecording[]; + filteredLabels: string[]; + onSubmit: (inputLabel: string) => void; } export const getLabelDisplay = (label: RecordingLabel) => `${label.key}:${label.value}`; @@ -71,7 +72,7 @@ export const LabelFilter: React.FunctionComponent = (props) => if (!r || !r.metadata) return; parseLabels(r.metadata.labels).map((label) => labels.add(getLabelDisplay(label))); }); - return Array.from(labels).sort(); + return Array.from(labels).filter((l) => !props.filteredLabels.includes(l)).sort(); }, [props.recordings, parseLabels, getLabelDisplay]); return ( diff --git a/src/app/Recordings/Filters/NameFilter.tsx b/src/app/Recordings/Filters/NameFilter.tsx index 3bd29d6d6..56eba38ad 100644 --- a/src/app/Recordings/Filters/NameFilter.tsx +++ b/src/app/Recordings/Filters/NameFilter.tsx @@ -42,6 +42,7 @@ import { ArchivedRecording } from '@app/Shared/Services/Api.service'; export interface NameFilterProps { recordings: ArchivedRecording[]; + filteredNames: string[]; onSubmit: (inputName: string) => void; } @@ -61,7 +62,9 @@ export const NameFilter: React.FunctionComponent = (props) => { [props.onSubmit, setIsOpen, setSelected] ); - const names = React.useMemo(() => props.recordings.map((r) => r.name), [props.recordings]); + const names = React.useMemo(() => { + return props.recordings.map((r) => r.name).filter((n) => !props.filteredNames.includes(n)); + }, [props.recordings, props.filteredNames]); return ( {labels.map((option, index) => ( - + ))} diff --git a/src/app/Recordings/Filters/NameFilter.tsx b/src/app/Recordings/Filters/NameFilter.tsx index 56eba38ad..81599bf53 100644 --- a/src/app/Recordings/Filters/NameFilter.tsx +++ b/src/app/Recordings/Filters/NameFilter.tsx @@ -47,39 +47,34 @@ export interface NameFilterProps { } export const NameFilter: React.FunctionComponent = (props) => { - const [isOpen, setIsOpen] = React.useState(false); - const [selected, setSelected] = React.useState(''); + const [isExpanded, setIsExpanded] = React.useState(false); + const onSelect = React.useCallback( - (event, selection, isPlaceholder) => { - if (isPlaceholder) { - setIsOpen(false); - setSelected(''); - } else { - setSelected(selection); + (_, selection, isPlaceholder) => { + if (!isPlaceholder) { + // No need to close menu as parent rebuilds props.onSubmit(selection); } - }, - [props.onSubmit, setIsOpen, setSelected] + }, [props.onSubmit] ); - const names = React.useMemo(() => { - return props.recordings.map((r) => r.name).filter((n) => !props.filteredNames.includes(n)); + const nameOptions = React.useMemo(() => { + return props.recordings.map((r) => r.name).filter((n) => !props.filteredNames.includes(n)).map((option, index) => ( + + )); }, [props.recordings, props.filteredNames]); return ( ); }; From 8e4596f9adfb63b453665b1691065e90c3460498 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 13 Sep 2022 15:57:55 -0400 Subject: [PATCH 47/98] fix(jest): add jest-dom as a setup file for nested test files --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index a67b84d59..c45553aa2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -32,7 +32,7 @@ module.exports = { preset: "ts-jest/presets/js-with-ts", // The path to a module that runs some code to configure or set up the testing framework before each test - setupFilesAfterEnv: ['/test-setup.js'], + setupFilesAfterEnv: ['/test-setup.js', "@testing-library/jest-dom"], // The test environment that will be used for testing. testEnvironment: "jsdom", From f4b5aefac99a6284b9379f9055feecc9b2094cd5 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 13 Sep 2022 16:11:00 -0400 Subject: [PATCH 48/98] test(filters): update tests for NameFilter --- .../Recordings/Filters/NameFilter.test.tsx | 118 +++++++++++++++--- .../__snapshots__/NameFilter.test.tsx.snap | 8 +- 2 files changed, 103 insertions(+), 23 deletions(-) diff --git a/src/test/Recordings/Filters/NameFilter.test.tsx b/src/test/Recordings/Filters/NameFilter.test.tsx index d65f57245..7b30fd08f 100644 --- a/src/test/Recordings/Filters/NameFilter.test.tsx +++ b/src/test/Recordings/Filters/NameFilter.test.tsx @@ -38,7 +38,7 @@ import { NameFilter } from '@app/Recordings/Filters/NameFilter'; import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; -import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import { cleanup, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import renderer, { act } from 'react-test-renderer'; @@ -63,16 +63,24 @@ const mockRecording: ActiveRecording = { const mockAnotherRecording = { ...mockRecording, name: 'anotherRecording' }; const mockRecordingList = [mockRecording, mockAnotherRecording]; -const onNameInput = jest.fn(); +const onNameInput = jest.fn((nameInput) => { /**Do nothing. Used for checking renders */}); + +describe("", () => { + let emptyFilteredNames: string[]; + let filteredNames: string[]; + + beforeEach(() => { + emptyFilteredNames = []; + filteredNames = [ mockRecording.name ]; + }); -describe("", () => { afterEach(cleanup); it('renders correctly', async () => { let tree; await act(async () => { tree = renderer.create( - + ); }); expect(tree.toJSON()).toMatchSnapshot(); @@ -80,7 +88,7 @@ describe("", () => { it ('display name selections when text input is clicked', async () => { render( - + ); const nameInput = screen.getByLabelText("Filter by name..."); expect(nameInput).toBeInTheDocument(); @@ -88,11 +96,11 @@ describe("", () => { userEvent.click(nameInput); - const selectMenu = await screen.findByLabelText("Filter by name"); + const selectMenu = await screen.findByRole("listbox", {name: "Filter by name"}) expect(selectMenu).toBeInTheDocument(); expect(selectMenu).toBeVisible(); - mockRecordingList.map((r) => { + mockRecordingList.forEach((r) => { const option = within(selectMenu).getByText(r.name); expect(option).toBeInTheDocument(); expect(option).toBeVisible(); @@ -101,7 +109,7 @@ describe("", () => { it ('display name selections when dropdown arrow is clicked', async () => { render( - + ); const dropDownArrow = screen.getByRole("button", { name: "Options menu"}); expect(dropDownArrow).toBeInTheDocument(); @@ -109,32 +117,105 @@ describe("", () => { userEvent.click(dropDownArrow); - const selectMenu = await screen.findByLabelText("Filter by name"); + const selectMenu = await screen.findByRole("listbox", {name: "Filter by name"}) expect(selectMenu).toBeInTheDocument(); expect(selectMenu).toBeVisible(); - mockRecordingList.map((r) => { + mockRecordingList.forEach((r) => { const option = within(selectMenu).getByText(r.name); expect(option).toBeInTheDocument(); expect(option).toBeVisible(); }); }); - it ('selects a name when a name option is clicked', async () => { + it ('should close selection menu when toggled with dropdown arrow', async () => { render( - + ); + + const dropDownArrow = screen.getByRole("button", { name: "Options menu"}); + expect(dropDownArrow).toBeInTheDocument(); + expect(dropDownArrow).toBeVisible(); + + userEvent.click(dropDownArrow); + + const selectMenu = await screen.findByRole("listbox", {name: "Filter by name"}) + expect(selectMenu).toBeInTheDocument(); + expect(selectMenu).toBeVisible(); + + mockRecordingList.forEach((r) => { + const option = within(selectMenu).getByText(r.name); + expect(option).toBeInTheDocument(); + expect(option).toBeVisible(); + }); + + userEvent.click(dropDownArrow); + expect(selectMenu).not.toBeInTheDocument(); + expect(selectMenu).not.toBeVisible(); + }); + + it ('should close selection menu when toggled with text input', async () => { + render( + + ); + + const nameInput = screen.getByLabelText("Filter by name..."); + expect(nameInput).toBeInTheDocument(); + expect(nameInput).toBeVisible(); + + userEvent.click(nameInput); + + const selectMenu = await screen.findByRole("listbox", {name: "Filter by name"}) + expect(selectMenu).toBeInTheDocument(); + expect(selectMenu).toBeVisible(); + + mockRecordingList.forEach((r) => { + const option = within(selectMenu).getByText(r.name); + expect(option).toBeInTheDocument(); + expect(option).toBeVisible(); + }); + + userEvent.click(nameInput); + expect(selectMenu).not.toBeInTheDocument(); + expect(selectMenu).not.toBeVisible(); + }); + + it ('should not display selected names', async () => { + render( + + ); + const nameInput = screen.getByLabelText("Filter by name..."); + expect(nameInput).toBeInTheDocument(); + expect(nameInput).toBeVisible(); + + userEvent.click(nameInput); + + const selectMenu = await screen.findByRole("listbox", {name: "Filter by name"}) + expect(selectMenu).toBeInTheDocument(); + expect(selectMenu).toBeVisible(); + + const notToShowName = within(selectMenu).queryByText(mockRecording.name); + expect(notToShowName).not.toBeInTheDocument(); + }); + + it ('should select a name when a name option is clicked', async () => { + const submitNameInput = jest.fn((nameInput) => emptyFilteredNames.push(nameInput)); + + render( + + ); + const nameInput = screen.getByLabelText("Filter by name..."); expect(nameInput).toBeInTheDocument(); expect(nameInput).toBeVisible(); userEvent.click(nameInput); - const selectMenu = await screen.findByLabelText("Filter by name"); + const selectMenu = await screen.findByRole("listbox", {name: "Filter by name"}) expect(selectMenu).toBeInTheDocument(); expect(selectMenu).toBeVisible(); - mockRecordingList.map((r) => { + mockRecordingList.forEach((r) => { const option = within(selectMenu).getByText(r.name); expect(option).toBeInTheDocument(); expect(option).toBeVisible(); @@ -142,11 +223,10 @@ describe("", () => { userEvent.click(screen.getByText("someRecording")); - // Should close menu - await waitFor(() => expect(selectMenu).not.toBeVisible()); - await waitFor(() => expect(selectMenu).not.toBeInTheDocument()); + // NameFilter's parent rebuilds to close menu by default. - expect(onNameInput).toBeCalledTimes(1); - expect(onNameInput).toBeCalledWith("someRecording"); + expect(submitNameInput).toBeCalledTimes(1); + expect(submitNameInput).toBeCalledWith("someRecording"); + expect(emptyFilteredNames).toStrictEqual([ "someRecording" ]); }); }); diff --git a/src/test/Recordings/Filters/__snapshots__/NameFilter.test.tsx.snap b/src/test/Recordings/Filters/__snapshots__/NameFilter.test.tsx.snap index 700753ebb..239a838bf 100644 --- a/src/test/Recordings/Filters/__snapshots__/NameFilter.test.tsx.snap +++ b/src/test/Recordings/Filters/__snapshots__/NameFilter.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders correctly 1`] = ` +exports[` renders correctly 1`] = `
renders correctly 1`] = ` autoComplete="off" className="pf-c-form-control pf-c-select__toggle-typeahead" disabled={false} - id="pf-select-toggle-id-2-select-typeahead" + id="pf-select-toggle-id-0-select-typeahead" onChange={[Function]} onClick={[Function]} placeholder="Filter by name..." @@ -33,10 +33,10 @@ exports[` renders correctly 1`] = ` aria-expanded={false} aria-haspopup="listbox" aria-label="Options menu" - aria-labelledby=" pf-select-toggle-id-2" + aria-labelledby=" pf-select-toggle-id-0" className="pf-c-button pf-c-select__toggle-button pf-m-plain" disabled={false} - id="pf-select-toggle-id-2" + id="pf-select-toggle-id-0" onClick={[Function]} tabIndex={-1} type="button" From fbf3bce20345e33abdfbb35188869f8e7e26d5b9 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 13 Sep 2022 16:46:23 -0400 Subject: [PATCH 49/98] test(filters): add unit test for LabelFilter --- src/app/Recordings/Filters/LabelFilter.tsx | 6 +- .../Recordings/Filters/LabelFilter.test.tsx | 237 ++++++++++++++++++ .../__snapshots__/LabelFilter.test.tsx.snap | 66 +++++ 3 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 src/test/Recordings/Filters/LabelFilter.test.tsx create mode 100644 src/test/Recordings/Filters/__snapshots__/LabelFilter.test.tsx.snap diff --git a/src/app/Recordings/Filters/LabelFilter.tsx b/src/app/Recordings/Filters/LabelFilter.tsx index 98531a57b..05b1cda42 100644 --- a/src/app/Recordings/Filters/LabelFilter.tsx +++ b/src/app/Recordings/Filters/LabelFilter.tsx @@ -64,7 +64,7 @@ export const LabelFilter: React.FunctionComponent = (props) => const labels = React.useMemo(() => { const labels = new Set(); props.recordings.forEach((r) => { - if (!r || !r.metadata) return; + if (!r || !r.metadata || !r.metadata.labels) return; parseLabels(r.metadata.labels).map((label) => labels.add(getLabelDisplay(label))); }); return Array.from(labels).filter((l) => !props.filteredLabels.includes(l)).sort(); @@ -73,11 +73,11 @@ export const LabelFilter: React.FunctionComponent = (props) => return ( +
+ + + +`; From 875bcffcb294a75216d8f6ccdad36160ed4c556a Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 13 Sep 2022 17:05:03 -0400 Subject: [PATCH 50/98] fix(filters): remove log calls --- src/app/RecordingMetadata/BulkEditLabels.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/RecordingMetadata/BulkEditLabels.tsx b/src/app/RecordingMetadata/BulkEditLabels.tsx index f566b70bf..d7035b632 100644 --- a/src/app/RecordingMetadata/BulkEditLabels.tsx +++ b/src/app/RecordingMetadata/BulkEditLabels.tsx @@ -63,7 +63,6 @@ export const BulkEditLabels: React.FunctionComponent = (pro const [valid, setValid] = React.useState(ValidatedOptions.default); const addSubscription = useSubscriptions(); - console.log(props.checkedIndices); const getIdxFromRecording = React.useCallback((r: ArchivedRecording): number => { if (isActiveRecording(r)) { return r.id; From 5e52ee75b4b4673117aecc42c1a2c3d0a7bb451b Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 13 Sep 2022 17:53:04 -0400 Subject: [PATCH 51/98] test(filter): add test for DurationFilter --- src/app/Recordings/Filters/DurationFilter.tsx | 20 +- .../Filters/DurationFilter.test.tsx | 201 ++++++++++++++++++ .../DurationFilter.test.tsx.snap | 57 +++++ 3 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 src/test/Recordings/Filters/DurationFilter.test.tsx create mode 100644 src/test/Recordings/Filters/__snapshots__/DurationFilter.test.tsx.snap diff --git a/src/app/Recordings/Filters/DurationFilter.tsx b/src/app/Recordings/Filters/DurationFilter.tsx index 030de3c83..15561ffe4 100644 --- a/src/app/Recordings/Filters/DurationFilter.tsx +++ b/src/app/Recordings/Filters/DurationFilter.tsx @@ -50,6 +50,17 @@ export const DurationFilter: React.FunctionComponent = (pro const isContinuous = React.useMemo(() => props.durations && props.durations.includes("continuous"), [props.durations]); + const handleContinousCheckBoxChange = React.useCallback((checked, envt) => { + props.onContinuousDurationSelect(checked); + }, [props.onContinuousDurationSelect]); + + const handleEnterKey = React.useCallback((e) => { + if (e.key && e.key !== 'Enter') { + return; + } + props.onDurationInput(duration) + }, [props.onDurationInput, duration]); + return ( @@ -60,12 +71,7 @@ export const DurationFilter: React.FunctionComponent = (pro aria-label="duration filter" onChange={(e) => setDuration(Number(e))} min="0" - onKeyDown={(e) => { - if (e.key && e.key !== 'Enter') { - return; - } - props.onDurationInput(duration); - }} + onKeyDown={handleEnterKey} /> @@ -73,7 +79,7 @@ export const DurationFilter: React.FunctionComponent = (pro label="Continuous" id="continuous-checkbox" isChecked={isContinuous} - onChange={props.onContinuousDurationSelect} + onChange={handleContinousCheckBoxChange} /> diff --git a/src/test/Recordings/Filters/DurationFilter.test.tsx b/src/test/Recordings/Filters/DurationFilter.test.tsx new file mode 100644 index 000000000..250082cb8 --- /dev/null +++ b/src/test/Recordings/Filters/DurationFilter.test.tsx @@ -0,0 +1,201 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DurationFilter } from '@app/Recordings/Filters/DurationFilter'; +import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; + +const mockRecordingLabels = { + someLabel: 'someValue', +}; +const mockRecording: ActiveRecording = { + name: 'someRecording', + downloadUrl: 'http://downloadUrl', + reportUrl: 'http://reportUrl', + metadata: { labels: mockRecordingLabels }, + startTime: 1234567890, + id: 0, + state: RecordingState.RUNNING, + duration: 30, + continuous: false, + toDisk: false, + maxSize: 0, + maxAge: 0, +}; + +const onDurationInput = jest.fn((durationInput) => { /**Do nothing. Used for checking renders */}); +const onContinuousSelect = jest.fn((continuous) => { /**Do nothing. Used for checking renders */}); + +describe("", () => { + let emptyFilteredDuration: string[]; + let filteredDurationsWithCont: string[]; + let filteredDurationsWithoutCont: string[]; + + beforeEach(() => { + emptyFilteredDuration = []; + filteredDurationsWithCont = [ `${mockRecording.duration}`, "continuous" ]; + filteredDurationsWithoutCont = [ `${mockRecording.duration}` ] + }); + + afterEach(cleanup); + + it('renders correctly', async () => { + let tree; + await act(async () => { + tree = renderer.create( + + ); + }); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it ('should check continous box if continous is in filter', () => { + render( + + ); + + const checkBox = screen.getByRole('checkbox', { name: "Continuous" }); + expect(checkBox).toBeInTheDocument(); + expect(checkBox).toBeVisible(); + expect(checkBox).toHaveAttribute("checked"); + }); + + it ('should not check continous box if continous is in filter', () => { + render( + + ); + + const checkBox = screen.getByRole('checkbox', { name: "Continuous" }); + expect(checkBox).toBeInTheDocument(); + expect(checkBox).toBeVisible(); + expect(checkBox).not.toHaveAttribute("checked"); + }); + + it ('should select continous when clicking unchecked continuous box', () => { + const submitContinous = jest.fn((continous) => { + filteredDurationsWithoutCont.push("continuous"); + }); + + render( + + ); + + const checkBox = screen.getByRole('checkbox', { name: "Continuous" }); + expect(checkBox).toBeInTheDocument(); + expect(checkBox).toBeVisible(); + expect(checkBox).not.toHaveAttribute("checked"); + + userEvent.click(checkBox); + + expect(submitContinous).toHaveBeenCalledTimes(1); + expect(submitContinous).toHaveBeenCalledWith(true); + + expect(filteredDurationsWithoutCont).toStrictEqual([`${mockRecording.duration}`, "continuous"]); + }); + + it ('should unselect continous when clicking checked continuous box', () => { + const submitContinous = jest.fn((continous) => { + filteredDurationsWithCont = filteredDurationsWithCont.filter((v) => v !== "continuous"); + }); + + render( + + ); + + const checkBox = screen.getByRole('checkbox', { name: "Continuous" }); + expect(checkBox).toBeInTheDocument(); + expect(checkBox).toBeVisible(); + expect(checkBox).toHaveAttribute("checked"); + + userEvent.click(checkBox); + + expect(submitContinous).toHaveBeenCalledTimes(1); + expect(submitContinous).toHaveBeenCalledWith(false); + expect(filteredDurationsWithCont).toStrictEqual([`${mockRecording.duration}`]); + }); + + it ('should select a duration when pressing Enter', async () => { + const submitDuration = jest.fn((duration) => { + emptyFilteredDuration.push(duration); + }); + + render( + + ); + + const durationInput = screen.getByLabelText("duration filter"); + expect(durationInput).toBeInTheDocument(); + expect(durationInput).toBeVisible(); + + userEvent.clear(durationInput); + userEvent.type(durationInput, "50"); + + // Press enter + userEvent.type(durationInput, "{enter}"); + + expect(submitDuration).toHaveBeenCalledTimes(1); + expect(submitDuration).toHaveBeenCalledWith(Number("50")); + expect(emptyFilteredDuration).toStrictEqual([50]); + }); + + it ('should not select a duration when pressing other keys', async () => { + const submitDuration = jest.fn((duration) => { + emptyFilteredDuration.push(duration); + }); + + render( + + ); + + const durationInput = screen.getByLabelText("duration filter"); + expect(durationInput).toBeInTheDocument(); + expect(durationInput).toBeVisible(); + + userEvent.clear(durationInput); + userEvent.type(durationInput, "50"); + + // Press shift + userEvent.type(durationInput, "{shift}"); + + expect(submitDuration).toHaveBeenCalledTimes(0); + expect(emptyFilteredDuration).toStrictEqual([]); + }); +}); diff --git a/src/test/Recordings/Filters/__snapshots__/DurationFilter.test.tsx.snap b/src/test/Recordings/Filters/__snapshots__/DurationFilter.test.tsx.snap new file mode 100644 index 000000000..fc5291283 --- /dev/null +++ b/src/test/Recordings/Filters/__snapshots__/DurationFilter.test.tsx.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +
+
+ +
+
+
+ + +
+
+
+`; From 4764fa471ecabe39aa25d2bbbd1f68fecdf4a5bd Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 13 Sep 2022 18:51:50 -0400 Subject: [PATCH 52/98] test(filter): add test for RecordingStateFilter --- .../Filters/RecordingStateFilter.tsx | 4 +- .../Filters/RecordingStateFilter.test.tsx | 224 ++++++++++++++++++ .../RecordingStateFilter.test.tsx.snap | 55 +++++ 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 src/test/Recordings/Filters/RecordingStateFilter.test.tsx create mode 100644 src/test/Recordings/Filters/__snapshots__/RecordingStateFilter.test.tsx.snap diff --git a/src/app/Recordings/Filters/RecordingStateFilter.tsx b/src/app/Recordings/Filters/RecordingStateFilter.tsx index b03b514a6..075ca2bfa 100644 --- a/src/app/Recordings/Filters/RecordingStateFilter.tsx +++ b/src/app/Recordings/Filters/RecordingStateFilter.tsx @@ -58,15 +58,15 @@ export const RecordingStateFilter: React.FunctionComponent { Object.values(RecordingState).map((rs) => ( - + )) } diff --git a/src/test/Recordings/Filters/RecordingStateFilter.test.tsx b/src/test/Recordings/Filters/RecordingStateFilter.test.tsx new file mode 100644 index 000000000..2ee221c80 --- /dev/null +++ b/src/test/Recordings/Filters/RecordingStateFilter.test.tsx @@ -0,0 +1,224 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + +import { RecordingStateFilter } from '@app/Recordings/Filters/RecordingStateFilter'; +import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; + +const mockRecordingLabels = { + someLabel: 'someValue', +}; +const mockRecording: ActiveRecording = { + name: 'someRecording', + downloadUrl: 'http://downloadUrl', + reportUrl: 'http://reportUrl', + metadata: { labels: mockRecordingLabels }, + startTime: 1234567890, + id: 0, + state: RecordingState.RUNNING, + duration: 0, + continuous: false, + toDisk: false, + maxSize: 0, + maxAge: 0, +}; +const mockAnotherRecording = { ...mockRecording, name: 'anotherRecording', state: RecordingState.STOPPED } as ActiveRecording; + +const onStateSelectToggle = jest.fn((state) => { /**Do nothing. Used for checking renders */}); + +describe("", () => { + let filteredStates: RecordingState[]; + let emptyFilteredStates: RecordingState[]; + + beforeEach(() => { + emptyFilteredStates = []; + filteredStates = [ mockRecording.state ]; + }); + + afterEach(cleanup); + + it('renders correctly', async () => { + let tree; + await act(async () => { + tree = renderer.create( + + ); + }); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it ('should display state selections when dropdown is clicked', async () => { + render( + + ); + + const stateDropDown = screen.getByRole('button', { name: "Options menu" }); + expect(stateDropDown).toBeInTheDocument(); + expect(stateDropDown).toBeVisible(); + + userEvent.click(stateDropDown); + + const selectMenu = await screen.findByRole("listbox", {name: "Filter by state"}) + expect(selectMenu).toBeInTheDocument(); + expect(selectMenu).toBeVisible(); + + Object.values(RecordingState).forEach((rs) => { + const selectOption = within(selectMenu).getByText(rs); + expect(selectOption).toBeInTheDocument(); + expect(selectOption).toBeVisible(); + }); + }); + + it ('should display filtered states as checked', async () => { + render( + + ); + + const stateDropDown = screen.getByRole('button', { name: "Options menu" }); + expect(stateDropDown).toBeInTheDocument(); + expect(stateDropDown).toBeVisible(); + + userEvent.click(stateDropDown); + + const selectMenu = await screen.findByRole("listbox", {name: "Filter by state"}) + expect(selectMenu).toBeInTheDocument(); + expect(selectMenu).toBeVisible(); + + Object.values(RecordingState).forEach((rs) => { + const selectOption = within(selectMenu).getByText(rs); + expect(selectOption).toBeInTheDocument(); + expect(selectOption).toBeVisible(); + }); + + const selectedOption = within(selectMenu).getByLabelText(`${mockRecording.state} State`); + expect(selectedOption).toBeInTheDocument(); + expect(selectedOption).toBeVisible(); + + const checkedBox = within(selectedOption).getByRole('checkbox'); + expect(checkedBox).toBeInTheDocument(); + expect(checkedBox).toBeVisible(); + expect(checkedBox).toHaveAttribute('checked'); + + }); + + it ('should select a state when clicking unchecked state box', async () => { + const onRecordingStateToggle = jest.fn((state) => { + emptyFilteredStates.push(mockRecording.state); + }); + + render( + + ); + + const stateDropDown = screen.getByRole('button', { name: "Options menu" }); + expect(stateDropDown).toBeInTheDocument(); + expect(stateDropDown).toBeVisible(); + + userEvent.click(stateDropDown); + + const selectMenu = await screen.findByRole("listbox", {name: "Filter by state"}) + expect(selectMenu).toBeInTheDocument(); + expect(selectMenu).toBeVisible(); + + Object.values(RecordingState).forEach((rs) => { + const selectOption = within(selectMenu).getByText(rs); + expect(selectOption).toBeInTheDocument(); + expect(selectOption).toBeVisible(); + }); + + const selectedOption = within(selectMenu).getByLabelText(`${mockRecording.state} State`); + expect(selectedOption).toBeInTheDocument(); + expect(selectedOption).toBeVisible(); + + const uncheckedBox = within(selectedOption).getByRole('checkbox'); + expect(uncheckedBox).toBeInTheDocument(); + expect(uncheckedBox).toBeVisible(); + expect(uncheckedBox).not.toHaveAttribute('checked'); + + userEvent.click(uncheckedBox); + + expect(onRecordingStateToggle).toHaveBeenCalledTimes(1); + expect(onRecordingStateToggle).toHaveBeenCalledWith(mockRecording.state); + expect(emptyFilteredStates).toStrictEqual([ mockRecording.state ]); + }); + + it ('should unselect a state when clicking checked state box', async () => { + const onRecordingStateToggle = jest.fn((state) => { + filteredStates = filteredStates.filter((state) => state !== state); + }); + + render( + + ); + + const stateDropDown = screen.getByRole('button', { name: "Options menu" }); + expect(stateDropDown).toBeInTheDocument(); + expect(stateDropDown).toBeVisible(); + + userEvent.click(stateDropDown); + + const selectMenu = await screen.findByRole("listbox", {name: "Filter by state"}) + expect(selectMenu).toBeInTheDocument(); + expect(selectMenu).toBeVisible(); + + Object.values(RecordingState).forEach((rs) => { + const selectOption = within(selectMenu).getByText(rs); + expect(selectOption).toBeInTheDocument(); + expect(selectOption).toBeVisible(); + }); + + const selectedOption = within(selectMenu).getByLabelText(`${mockRecording.state} State`); + expect(selectedOption).toBeInTheDocument(); + expect(selectedOption).toBeVisible(); + + const uncheckedBox = within(selectedOption).getByRole('checkbox'); + expect(uncheckedBox).toBeInTheDocument(); + expect(uncheckedBox).toBeVisible(); + expect(uncheckedBox).toHaveAttribute('checked'); + + userEvent.click(uncheckedBox); + + expect(onRecordingStateToggle).toHaveBeenCalledTimes(1); + expect(onRecordingStateToggle).toHaveBeenCalledWith(mockRecording.state); + expect(filteredStates).toStrictEqual([]); + }); +}); diff --git a/src/test/Recordings/Filters/__snapshots__/RecordingStateFilter.test.tsx.snap b/src/test/Recordings/Filters/__snapshots__/RecordingStateFilter.test.tsx.snap new file mode 100644 index 000000000..8450135d0 --- /dev/null +++ b/src/test/Recordings/Filters/__snapshots__/RecordingStateFilter.test.tsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +
+ +
+`; From b9ebc738d676a90ae578f65b83d176c029f13895 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 13 Sep 2022 18:59:13 -0400 Subject: [PATCH 53/98] test(filters): fix RecordingStateFilter tests --- .../Recordings/Filters/RecordingStateFilter.test.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/test/Recordings/Filters/RecordingStateFilter.test.tsx b/src/test/Recordings/Filters/RecordingStateFilter.test.tsx index 2ee221c80..10a1f3e7f 100644 --- a/src/test/Recordings/Filters/RecordingStateFilter.test.tsx +++ b/src/test/Recordings/Filters/RecordingStateFilter.test.tsx @@ -137,12 +137,11 @@ describe("", () => { expect(checkedBox).toBeInTheDocument(); expect(checkedBox).toBeVisible(); expect(checkedBox).toHaveAttribute('checked'); - }); it ('should select a state when clicking unchecked state box', async () => { const onRecordingStateToggle = jest.fn((state) => { - emptyFilteredStates.push(mockRecording.state); + emptyFilteredStates.push(state); }); render( @@ -165,7 +164,7 @@ describe("", () => { expect(selectOption).toBeVisible(); }); - const selectedOption = within(selectMenu).getByLabelText(`${mockRecording.state} State`); + const selectedOption = within(selectMenu).getByLabelText(`${mockAnotherRecording.state} State`); expect(selectedOption).toBeInTheDocument(); expect(selectedOption).toBeVisible(); @@ -177,13 +176,13 @@ describe("", () => { userEvent.click(uncheckedBox); expect(onRecordingStateToggle).toHaveBeenCalledTimes(1); - expect(onRecordingStateToggle).toHaveBeenCalledWith(mockRecording.state); - expect(emptyFilteredStates).toStrictEqual([ mockRecording.state ]); + expect(onRecordingStateToggle).toHaveBeenCalledWith(mockAnotherRecording.state); + expect(emptyFilteredStates).toStrictEqual([ mockAnotherRecording.state ]); }); it ('should unselect a state when clicking checked state box', async () => { const onRecordingStateToggle = jest.fn((state) => { - filteredStates = filteredStates.filter((state) => state !== state); + filteredStates = filteredStates.filter((rs) => state !== rs); }); render( From 85f8110cf8fed4c455e454fc28a0c4aa489f059d Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 14 Sep 2022 03:19:27 -0400 Subject: [PATCH 54/98] fix(datetimepicker): set document.body as default element to bind popover --- src/app/Recordings/ActiveRecordingsTable.tsx | 32 +++++++++---------- src/app/Recordings/Filters/DateTimePicker.tsx | 17 +++++++--- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 7f8bdb57a..884eb3f3c 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -564,24 +564,22 @@ export const ActiveRecordingsTable: React.FunctionComponent - - + + } - tableColumns={tableColumns} - isHeaderChecked={headerChecked} - onHeaderCheck={handleHeaderCheck} - isEmpty={!recordings.length} - isEmptyFilterResult={!filteredRecordings.length} - clearFilters={handleClearFilters} - isLoading ={isLoading} - isNestedTable={false} - errorMessage ={errorMessage} + tableTitle="Active Flight Recordings" + toolbar={} + tableColumns={tableColumns} + isHeaderChecked={headerChecked} + onHeaderCheck={handleHeaderCheck} + isEmpty={!recordings.length} + isEmptyFilterResult={!filteredRecordings.length} + clearFilters={handleClearFilters} + isLoading ={isLoading} + isNestedTable={false} + errorMessage ={errorMessage} > {recordingRows} diff --git a/src/app/Recordings/Filters/DateTimePicker.tsx b/src/app/Recordings/Filters/DateTimePicker.tsx index 99657b930..3d35fc641 100644 --- a/src/app/Recordings/Filters/DateTimePicker.tsx +++ b/src/app/Recordings/Filters/DateTimePicker.tsx @@ -36,7 +36,6 @@ * SOFTWARE. */ - import { Button, ButtonVariant, @@ -88,8 +87,10 @@ export const DateTimePicker: React.FunctionComponent = (pro props.onSubmit(`${selectedDate.toISOString()}`); }, [selectedDate, props.onSubmit]); - // Append the popever menu to the component higher in the tree to avoid cut-off and misalignment. - const elementToAppend = () => document.getElementById("active-recording-drawer")!; + // Append the popover date menu to the higher component in the tree to avoid cut-off. + // Potential cause: A parent container has "overflow: hidden". + // Caution: Lose accessibility support if using document.body + const elementToAppend = () => document.getElementById("active-recording-drawer") || document.body; return ( @@ -99,7 +100,15 @@ export const DateTimePicker: React.FunctionComponent = (pro onChange={onDateChange} aria-label="Date Picker" placeholder="YYYY-MM-DD" /> - + UTC From d588d97bf758b24547601215dab6cc0752ff237e Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 14 Sep 2022 04:29:15 -0400 Subject: [PATCH 55/98] test(filters): check if recording state filter is closed on toggle --- .../Filters/RecordingStateFilter.test.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/test/Recordings/Filters/RecordingStateFilter.test.tsx b/src/test/Recordings/Filters/RecordingStateFilter.test.tsx index 10a1f3e7f..748c7f4ac 100644 --- a/src/test/Recordings/Filters/RecordingStateFilter.test.tsx +++ b/src/test/Recordings/Filters/RecordingStateFilter.test.tsx @@ -108,6 +108,32 @@ describe("", () => { }); }); + it ('should close state selections when dropdown is toggled', async () => { + render( + + ); + + const stateDropDown = screen.getByRole('button', { name: "Options menu" }); + expect(stateDropDown).toBeInTheDocument(); + expect(stateDropDown).toBeVisible(); + + userEvent.click(stateDropDown); + + const selectMenu = await screen.findByRole("listbox", {name: "Filter by state"}) + expect(selectMenu).toBeInTheDocument(); + expect(selectMenu).toBeVisible(); + + Object.values(RecordingState).forEach((rs) => { + const selectOption = within(selectMenu).getByText(rs); + expect(selectOption).toBeInTheDocument(); + expect(selectOption).toBeVisible(); + }); + + userEvent.click(stateDropDown); + expect(selectMenu).not.toBeInTheDocument(); + expect(selectMenu).not.toBeVisible(); + }); + it ('should display filtered states as checked', async () => { render( From 5ac9bb46837532c98d27d31162f691450f203fef Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 14 Sep 2022 05:47:09 -0400 Subject: [PATCH 56/98] fix(datetimepicker): datetime is now correct without time set and time change should be reflected in datetime --- src/app/Recordings/Filters/DateTimePicker.tsx | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/app/Recordings/Filters/DateTimePicker.tsx b/src/app/Recordings/Filters/DateTimePicker.tsx index 3d35fc641..4deed9e5d 100644 --- a/src/app/Recordings/Filters/DateTimePicker.tsx +++ b/src/app/Recordings/Filters/DateTimePicker.tsx @@ -55,28 +55,24 @@ export interface DateTimePickerProps { } export const DateTimePicker: React.FunctionComponent = (props) => { - const [selectedDate, setSelectedDate] = React.useState(new Date(0)); - const [searchDisabled, setSearchDisabled] = React.useState(true); + const [selectedDate, setSelectedDate] = React.useState(); + const [selectedHour, setSelectedHour] = React.useState(0); + const [selectedMinute, setSelectedMinute] = React.useState(0); const [isTimeOpen, setIsTimeOpen] = React.useState(false); const onDateChange = React.useCallback( - (inputDate, newDate) => { - const valid = isValidDate(selectedDate) && isValidDate(newDate) && inputDate === yyyyMMddFormat(newDate); - if (valid) { - setSelectedDate(new Date(newDate)); - } - setSearchDisabled(!valid); + (inputDateValue: string, newDate: Date | undefined) => { + setSelectedDate(newDate); }, - [selectedDate, isValidDate, yyyyMMddFormat] + [isValidDate, yyyyMMddFormat, setSelectedDate] ); const onTimeChange = React.useCallback( (_, hour, minute) => { - let updated = new Date(selectedDate); - updated.setUTCHours(hour, minute); - setSelectedDate(updated); + setSelectedHour(hour); + setSelectedMinute(minute); }, - [selectedDate, setSelectedDate, isValidDate] + [setSelectedHour, setSelectedMinute] ); const onTimeToggle = React.useCallback((opened) => { @@ -84,8 +80,9 @@ export const DateTimePicker: React.FunctionComponent = (pro }, [setIsTimeOpen]); const handleSubmit = React.useCallback(() => { - props.onSubmit(`${selectedDate.toISOString()}`); - }, [selectedDate, props.onSubmit]); + selectedDate!.setUTCHours(selectedHour, selectedMinute); + props.onSubmit(`${selectedDate!.toISOString()}`); + }, [selectedDate, selectedHour, selectedMinute, props.onSubmit]); // Append the popover date menu to the higher component in the tree to avoid cut-off. // Potential cause: A parent container has "overflow: hidden". @@ -114,7 +111,7 @@ export const DateTimePicker: React.FunctionComponent = (pro UTC - From 7abe2f463851b6c5a84798da966f8fe568dc77f7 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 14 Sep 2022 06:08:24 -0400 Subject: [PATCH 57/98] test(filters): add tests for DateTimePicker --- .../Filters/DateTimePicker.test.tsx | 369 ++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 src/test/Recordings/Filters/DateTimePicker.test.tsx diff --git a/src/test/Recordings/Filters/DateTimePicker.test.tsx b/src/test/Recordings/Filters/DateTimePicker.test.tsx new file mode 100644 index 000000000..4af73e663 --- /dev/null +++ b/src/test/Recordings/Filters/DateTimePicker.test.tsx @@ -0,0 +1,369 @@ +import { DateTimePicker } from "@app/Recordings/Filters/DateTimePicker"; + +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +const onDateTimeSelect = jest.fn((date) => { /**Do nothing. Used for checking renders */ }); +const currentDate = new Date(0); +currentDate.setUTCDate(14); +currentDate.setUTCFullYear(2022, 8, 14); // 8 is September + +describe("", () => { + + beforeEach(() => { + // Mock system time + jest.useFakeTimers("modern").setSystemTime(currentDate); + }); + + afterEach(cleanup); + + it('renders correctly', async () => { /** Skip snapshot test as component depends on DOM */ }); + + it('should open calendar when calendar icon is clicked', async () => { + render( + + ); + + const calendarIcon = screen.getByRole('button', { name: "Toggle date picker"}); + expect(calendarIcon).toBeInTheDocument(); + expect(calendarIcon).toBeVisible(); + + userEvent.click(calendarIcon); + + const calendar = await screen.findByRole("dialog"); + expect(calendar).toBeInTheDocument(); + expect(calendar).toBeVisible(); + }); + + it('should open time menu when time input is clicked', async () => { + render( + + ); + + const timeInput = screen.getByLabelText("Time Picker"); + expect(timeInput).toBeInTheDocument(); + expect(timeInput).toBeVisible(); + + userEvent.click(timeInput); + + const timeMenu = await screen.findByRole("menu", { name: "Time Picker"}); + expect(timeMenu).toBeInTheDocument(); + expect(timeMenu).toBeVisible(); + }); + + it('should close time menu when time input is clicked', async () => { + render( + + ); + + const timeInput = screen.getByLabelText("Time Picker"); + expect(timeInput).toBeInTheDocument(); + expect(timeInput).toBeVisible(); + + userEvent.click(timeInput); + + const timeMenu = await screen.findByRole("menu", { name: "Time Picker"}); + expect(timeMenu).toBeInTheDocument(); + expect(timeMenu).toBeVisible(); + + userEvent.click(document.body); // Click elsewhere + + expect(timeMenu).not.toBeInTheDocument(); + expect(timeMenu).not.toBeVisible(); + }); + + it('should close calendar when calendar icon is clicked', async () => { + render( + + ); + + const calendarIcon = screen.getByRole('button', { name: "Toggle date picker"}); + expect(calendarIcon).toBeInTheDocument(); + expect(calendarIcon).toBeVisible(); + + userEvent.click(calendarIcon); + + const calendar = await screen.findByRole("dialog"); + expect(calendar).toBeInTheDocument(); + expect(calendar).toBeVisible(); + + userEvent.click(calendarIcon); + + await waitFor(() => { + expect(calendar).not.toBeInTheDocument(); + expect(calendar).not.toBeVisible(); + }); + }); + + it('should disable search icon when no date is selected', () => { + render( + + ); + + const searchIcon = screen.getByRole('button', {name: "Search For Date"}); + expect(searchIcon).toBeInTheDocument(); + expect(searchIcon).toBeVisible(); + expect(searchIcon).toBeDisabled(); + }); + + it('should still disable search icon when time is selected', async () => { + render( + + ); + + const timeInput = screen.getByLabelText("Time Picker"); + expect(timeInput).toBeInTheDocument(); + expect(timeInput).toBeVisible(); + + userEvent.click(timeInput); + + const timeMenu = await screen.findByRole("menu", { name: "Time Picker"}); + expect(timeMenu).toBeInTheDocument(); + expect(timeMenu).toBeVisible(); + + const noonOption = within(timeMenu).getByRole('menuitem', { name: "12:00"}) + expect(noonOption).toBeInTheDocument(); + expect(noonOption).toBeVisible(); + + userEvent.click(noonOption); + + expect(timeMenu).not.toBeInTheDocument(); + expect(timeMenu).not.toBeVisible(); + + const searchIcon = screen.getByRole('button', {name: "Search For Date"}); + expect(searchIcon).toBeInTheDocument(); + expect(searchIcon).toBeVisible(); + expect(searchIcon).toBeDisabled(); + }); + + it('should enable search icon when date is selected', async () => { + render( + + ); + + const calendarIcon = screen.getByRole('button', { name: "Toggle date picker"}); + expect(calendarIcon).toBeInTheDocument(); + expect(calendarIcon).toBeVisible(); + + userEvent.click(calendarIcon); + + const calendar = await screen.findByRole("dialog"); + expect(calendar).toBeInTheDocument(); + expect(calendar).toBeVisible(); + + const dateOption = within(calendar).getByRole('button', {name: "14 September 2022"}); + expect(dateOption).toBeInTheDocument(); + expect(dateOption).toBeVisible(); + + userEvent.click(dateOption); + + await waitFor(() => { + expect(calendar).not.toBeInTheDocument(); + expect(calendar).not.toBeVisible(); + }); + + const searchIcon = screen.getByRole('button', {name: "Search For Date"}); + expect(searchIcon).toBeInTheDocument(); + expect(searchIcon).toBeVisible(); + expect(searchIcon).not.toBeDisabled(); + }); + + it('should enable search icon when a valid date is entered', async () => { + render( + + ); + + const dateInput = screen.getByLabelText("Date Picker"); + expect(dateInput).toBeInTheDocument(); + expect(dateInput).toBeVisible(); + + userEvent.type(dateInput, "2022-09-14"); + userEvent.type(dateInput, "{enter}"); + + const searchIcon = screen.getByRole('button', {name: "Search For Date"}); + expect(searchIcon).toBeInTheDocument(); + expect(searchIcon).toBeVisible(); + expect(searchIcon).not.toBeDisabled(); + }); + + it('should show error when an invalid date is entered', async () => { + render( + + ); + + const dateInput = screen.getByLabelText("Date Picker"); + expect(dateInput).toBeInTheDocument(); + expect(dateInput).toBeVisible(); + + userEvent.type(dateInput, "invalid_date"); + userEvent.type(dateInput, "{enter}"); + + const searchIcon = screen.getByRole('button', {name: "Search For Date"}); + expect(searchIcon).toBeInTheDocument(); + expect(searchIcon).toBeVisible(); + expect(searchIcon).toBeDisabled(); + + const errorMessage = await screen.findByText("Invalid date"); + expect(errorMessage).toBeInTheDocument(); + expect(errorMessage).toBeVisible(); + }); + + it('should show error when an invalid time is entered', async () => { + render( + + ); + + const timeInput = screen.getByLabelText("Time Picker"); + expect(timeInput).toBeInTheDocument(); + expect(timeInput).toBeVisible(); + + userEvent.type(timeInput, "invalid_time"); + userEvent.type(timeInput, "{enter}"); + + const searchIcon = screen.getByRole('button', {name: "Search For Date"}); + expect(searchIcon).toBeInTheDocument(); + expect(searchIcon).toBeVisible(); + expect(searchIcon).toBeDisabled(); + + const errorMessage = await screen.findByText("Invalid time format"); + expect(errorMessage).toBeInTheDocument(); + expect(errorMessage).toBeVisible(); + }); + + it('should update date time when date is selected and search icon is clicked', async () => { + render( + + ); + + const calendarIcon = screen.getByRole('button', { name: "Toggle date picker"}); + expect(calendarIcon).toBeInTheDocument(); + expect(calendarIcon).toBeVisible(); + + userEvent.click(calendarIcon); + + const calendar = await screen.findByRole("dialog"); + expect(calendar).toBeInTheDocument(); + expect(calendar).toBeVisible(); + + const dateOption = within(calendar).getByRole('button', {name: "14 September 2022"}); + expect(dateOption).toBeInTheDocument(); + expect(dateOption).toBeVisible(); + + userEvent.click(dateOption); + + await waitFor(() => { + expect(calendar).not.toBeInTheDocument(); + expect(calendar).not.toBeVisible(); + }); + + const searchIcon = screen.getByRole('button', {name: "Search For Date"}); + expect(searchIcon).toBeInTheDocument(); + expect(searchIcon).toBeVisible(); + expect(searchIcon).not.toBeDisabled(); + + userEvent.click(searchIcon); + + expect(onDateTimeSelect).toHaveBeenCalledTimes(1); + expect(onDateTimeSelect).toHaveBeenCalledWith(currentDate.toISOString()); + }); + + it('should update date time when both date and time are selected and search icon is clicked', async () => { + render( + + ); + + // Select a date + const calendarIcon = screen.getByRole('button', { name: "Toggle date picker"}); + expect(calendarIcon).toBeInTheDocument(); + expect(calendarIcon).toBeVisible(); + + userEvent.click(calendarIcon); + + const calendar = await screen.findByRole("dialog"); + expect(calendar).toBeInTheDocument(); + expect(calendar).toBeVisible(); + + const dateOption = within(calendar).getByRole('button', {name: "14 September 2022"}); + expect(dateOption).toBeInTheDocument(); + expect(dateOption).toBeVisible(); + + userEvent.click(dateOption); + + await waitFor(() => { + expect(calendar).not.toBeInTheDocument(); + expect(calendar).not.toBeVisible(); + }); + + // Select a time + const timeInput = screen.getByLabelText("Time Picker"); + expect(timeInput).toBeInTheDocument(); + expect(timeInput).toBeVisible(); + + userEvent.click(timeInput); + + const timeMenu = await screen.findByRole("menu", { name: "Time Picker"}); + expect(timeMenu).toBeInTheDocument(); + expect(timeMenu).toBeVisible(); + + const noonOption = within(timeMenu).getByRole('menuitem', { name: "12:00"}) + expect(noonOption).toBeInTheDocument(); + expect(noonOption).toBeVisible(); + + userEvent.click(noonOption); + + expect(timeMenu).not.toBeInTheDocument(); + expect(timeMenu).not.toBeVisible(); + + // Submit + const searchIcon = screen.getByRole('button', {name: "Search For Date"}); + expect(searchIcon).toBeInTheDocument(); + expect(searchIcon).toBeVisible(); + expect(searchIcon).not.toBeDisabled(); + + userEvent.click(searchIcon); + + expect(onDateTimeSelect).toHaveBeenCalledTimes(1); + const expectedDate = new Date(currentDate); + expectedDate.setUTCHours(12, 0); + expect(onDateTimeSelect).toHaveBeenCalledWith(expectedDate.toISOString()); + }); +}); From 11da963c73626889f91c2d61551275606d59f56f Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 14 Sep 2022 16:26:11 -0400 Subject: [PATCH 58/98] fix(datetimepicker): move func to find menu mount point outside component --- src/app/Recordings/ActiveRecordingsTable.tsx | 3 ++- src/app/Recordings/Filters/DateTimePicker.tsx | 6 +++++- src/app/Recordings/RecordingFilters.tsx | 5 ++++- src/app/utils/utils.ts | 3 +++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 884eb3f3c..d21f5ac17 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -59,6 +59,7 @@ import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; import { useDispatch, useSelector } from 'react-redux'; import { addFilterIntent, addTargetIntent, deleteAllFiltersIntent, deleteCategoryFiltersIntent, deleteFilterIntent } from '@app/Shared/Redux/RecordingFilterActions'; import { TargetRecordingFilters } from '@app/Shared/Redux/RecordingFilterReducer'; +import { menuMountPointId } from '@app/utils/utils'; export enum PanelContent { LABELS, @@ -563,7 +564,7 @@ export const ActiveRecordingsTable: React.FunctionComponent + diff --git a/src/app/Recordings/Filters/DateTimePicker.tsx b/src/app/Recordings/Filters/DateTimePicker.tsx index 4deed9e5d..edfb74fcd 100644 --- a/src/app/Recordings/Filters/DateTimePicker.tsx +++ b/src/app/Recordings/Filters/DateTimePicker.tsx @@ -36,6 +36,7 @@ * SOFTWARE. */ +import { findDatetimeMenuMountPoint } from '@app/utils/utils'; import { Button, ButtonVariant, @@ -52,6 +53,7 @@ import React from 'react'; export interface DateTimePickerProps { onSubmit: (date: any) => void; + menuMountId?: string; } export const DateTimePicker: React.FunctionComponent = (props) => { @@ -87,7 +89,9 @@ export const DateTimePicker: React.FunctionComponent = (pro // Append the popover date menu to the higher component in the tree to avoid cut-off. // Potential cause: A parent container has "overflow: hidden". // Caution: Lose accessibility support if using document.body - const elementToAppend = () => document.getElementById("active-recording-drawer") || document.body; + const elementToAppend = React.useMemo(() => { + return props.menuMountId? findDatetimeMenuMountPoint(props.menuMountId): "parent"; + }, [props.menuMountId]); return ( diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index b02bb66fc..ecb9e02a3 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -59,6 +59,7 @@ import { RecordingState } from '@app/Shared/Services/Api.service'; import { useDispatch, useSelector } from 'react-redux'; import { UpdateFilterOptions } from '@app/Shared/Redux/RecordingFilterReducer'; import { updateCategoryIntent } from '@app/Shared/Redux/RecordingFilterActions'; +import { menuMountPointId } from '@app/utils/utils'; export interface RecordingFiltersCategories { Name: string[], @@ -185,6 +186,8 @@ export const RecordingFilters: React.FunctionComponent = ); }, [Object.keys(props.filters), isCategoryDropdownOpen, currentCategory, onCategoryToggle, onCategorySelect]); + const dateTimeMountId = React.useMemo(() => menuMountPointId(props.isArchived), [props.isArchived, menuMountPointId]) + const filterDropdownItems = React.useMemo( () => [ @@ -197,7 +200,7 @@ export const RecordingFilters: React.FunctionComponent = , - + , diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index ef2c20b37..cf4170027 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -51,3 +51,6 @@ export function accessibleRouteChangeHandler() { } }, 50); } + +export const menuMountPointId = (isArchived: boolean) => `${isArchived? "active": "archived"}-recording-drawer`; +export const findDatetimeMenuMountPoint = (id: string) => document.getElementById(id) || document.body; From 598c12d78d877c727d0e8c30c7d1f392731faa47 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 14 Sep 2022 16:56:55 -0400 Subject: [PATCH 59/98] test(datepicker): fix test imports --- src/test/Recordings/Filters/DateTimePicker.test.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/test/Recordings/Filters/DateTimePicker.test.tsx b/src/test/Recordings/Filters/DateTimePicker.test.tsx index 4af73e663..8484d9cd3 100644 --- a/src/test/Recordings/Filters/DateTimePicker.test.tsx +++ b/src/test/Recordings/Filters/DateTimePicker.test.tsx @@ -1,5 +1,3 @@ -import { DateTimePicker } from "@app/Recordings/Filters/DateTimePicker"; - /* * Copyright The Cryostat Authors * @@ -38,7 +36,8 @@ import { DateTimePicker } from "@app/Recordings/Filters/DateTimePicker"; * SOFTWARE. */ -import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import { DateTimePicker } from "@app/Recordings/Filters/DateTimePicker"; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -48,7 +47,6 @@ currentDate.setUTCDate(14); currentDate.setUTCFullYear(2022, 8, 14); // 8 is September describe("", () => { - beforeEach(() => { // Mock system time jest.useFakeTimers("modern").setSystemTime(currentDate); From 77b66b2437d7e5b9c7cb8592588e15a8902cd1a8 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 14 Sep 2022 18:13:22 -0400 Subject: [PATCH 60/98] chore(filters): move ClickableLabel to separate source file --- src/app/RecordingMetadata/ClickableLabel.tsx | 87 ++++++++++++++++++++ src/app/RecordingMetadata/LabelCell.tsx | 45 +--------- 2 files changed, 88 insertions(+), 44 deletions(-) create mode 100644 src/app/RecordingMetadata/ClickableLabel.tsx diff --git a/src/app/RecordingMetadata/ClickableLabel.tsx b/src/app/RecordingMetadata/ClickableLabel.tsx new file mode 100644 index 000000000..13752f015 --- /dev/null +++ b/src/app/RecordingMetadata/ClickableLabel.tsx @@ -0,0 +1,87 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { getLabelDisplay } from "@app/Recordings/Filters/LabelFilter"; +import { Label } from "@patternfly/react-core"; +import React from "react"; +import { RecordingLabel } from "./RecordingLabel"; + + +export interface ClickableLabelCellProps { + label: RecordingLabel; + isSelected: boolean; + onLabelClick: (label: RecordingLabel) => void +} + +export const ClickableLabel: React.FunctionComponent = (props) => { + const [isHoveredOrFocused, setIsHoveredOrFocused] = React.useState(false); + const labelColor = React.useMemo(() => props.isSelected? "blue": "grey", [props.isSelected]); + + const handleHoveredOrFocused = React.useCallback(() => setIsHoveredOrFocused(true), [setIsHoveredOrFocused]); + const handleNonHoveredOrFocused = React.useCallback(() => setIsHoveredOrFocused(false), [setIsHoveredOrFocused]); + + const style = React.useMemo(() => { + if (isHoveredOrFocused) { + const defaultStyle = { cursor: "pointer", "--pf-c-label__content--before--BorderWidth": "2.5px"}; + if (props.isSelected) { + return {...defaultStyle, "--pf-c-label__content--before--BorderColor": "#06c"} + } + return {...defaultStyle, "--pf-c-label__content--before--BorderColor": "#8a8d90"} + } + return {}; + }, [props.isSelected, isHoveredOrFocused]); + + const handleLabelClicked = React.useCallback( + () => props.onLabelClick(props.label), + [props.label, props.onLabelClick, getLabelDisplay] + ); + + return <> + + ; +} diff --git a/src/app/RecordingMetadata/LabelCell.tsx b/src/app/RecordingMetadata/LabelCell.tsx index c62dd27b2..525e3bfad 100644 --- a/src/app/RecordingMetadata/LabelCell.tsx +++ b/src/app/RecordingMetadata/LabelCell.tsx @@ -40,6 +40,7 @@ import { getLabelDisplay } from '@app/Recordings/Filters/LabelFilter'; import { UpdateFilterOptions } from '@app/Shared/Redux/RecordingFilterReducer'; import { Label, Text } from '@patternfly/react-core'; import React from 'react'; +import { ClickableLabel } from './ClickableLabel'; import { RecordingLabel } from './RecordingLabel'; export interface LabelCellProps { @@ -89,47 +90,3 @@ export const LabelCell: React.FunctionComponent = (props) => { ); }; - -export interface ClickableLabelCellProps { - label: RecordingLabel; - isSelected: boolean; - onLabelClick: (label: RecordingLabel) => void -} - -export const ClickableLabel: React.FunctionComponent = (props) => { - const [isHoveredOrFocused, setIsHoveredOrFocused] = React.useState(false); - const labelColor = React.useMemo(() => props.isSelected? "blue": "grey", [props.isSelected]); - - const handleHoveredOrFocused = React.useCallback(() => setIsHoveredOrFocused(true), [setIsHoveredOrFocused]); - const handleNonHoveredOrFocused = React.useCallback(() => setIsHoveredOrFocused(false), [setIsHoveredOrFocused]); - - const style = React.useMemo(() => { - if (isHoveredOrFocused) { - const defaultStyle = { cursor: "pointer", "--pf-c-label__content--before--BorderWidth": "2.5px"}; - if (props.isSelected) { - return {...defaultStyle, "--pf-c-label__content--before--BorderColor": "#06c"} - } - return {...defaultStyle, "--pf-c-label__content--before--BorderColor": "#8a8d90"} - } - return {}; - }, [props.isSelected, isHoveredOrFocused]); - - const handleLabelClicked = React.useCallback( - () => props.onLabelClick(props.label), - [props.label, props.onLabelClick, getLabelDisplay] - ); - - return <> - - ; -} From 0edc084ba84322f825bb40ae7b5337bf8e4e104e Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 14 Sep 2022 20:25:42 -0400 Subject: [PATCH 61/98] test(clickable-label): add unit tests for clickable label --- src/app/RecordingMetadata/ClickableLabel.tsx | 1 + .../ClickableLabel.test.tsx | 187 ++++++++++++++++++ .../RecordingMetadata.tsx/LabelCell.test.tsx | 2 +- .../ClickableLabel.test.tsx.snap | 19 ++ .../__snapshots__/LabelCell.test.tsx.snap | 15 -- 5 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 src/test/RecordingMetadata.tsx/ClickableLabel.test.tsx create mode 100644 src/test/RecordingMetadata.tsx/__snapshots__/ClickableLabel.test.tsx.snap delete mode 100644 src/test/RecordingMetadata.tsx/__snapshots__/LabelCell.test.tsx.snap diff --git a/src/app/RecordingMetadata/ClickableLabel.tsx b/src/app/RecordingMetadata/ClickableLabel.tsx index 13752f015..93a37151c 100644 --- a/src/app/RecordingMetadata/ClickableLabel.tsx +++ b/src/app/RecordingMetadata/ClickableLabel.tsx @@ -73,6 +73,7 @@ export const ClickableLabel: React.FunctionComponent = return <>