From 6a70f83705a88badb572a84982bde4c76eff6923 Mon Sep 17 00:00:00 2001 From: Janelle Law Date: Thu, 28 Jul 2022 16:57:33 -0400 Subject: [PATCH] feat(recordings): Add search filters to target recording tables (#486) * feat(recordings): add empty filter components * add datetime picker props * fix filter chip indexing * implement delete name filters * implement duration setter * add recording state chips * Add units to category labels * extract/format dateRange, add duration chips * rename filter component * attempt to fix callback deps * hoist filtering logic to table * extract name filter to typeahead select * fix available date range * add label filter * label fixup * filter archived recordings * set date picker timezone to UTC * split up date range into before or after date * clean up filter setters and fix empty label query * reuse filter logic for both tables * fixup! clean up filter setters and fix empty label query * mock filters in unit tests * fix license header * fix typo and minor refactor * revert all archive view, fix target archive clear filter --- src/app/Recordings/ActiveRecordingsTable.tsx | 76 +++- .../Recordings/ArchivedRecordingsTable.tsx | 36 +- src/app/Recordings/DateTimePicker.tsx | 109 ++++++ src/app/Recordings/LabelFilter.tsx | 138 +++++++ src/app/Recordings/NameFilter.tsx | 121 ++++++ src/app/Recordings/RecordingFilters.tsx | 349 ++++++++++++++++++ src/app/Recordings/RecordingsTable.tsx | 22 +- src/app/app.css | 13 + .../Recordings/ActiveRecordingsTable.test.tsx | 11 + .../ArchivedRecordingsTable.test.tsx | 11 + .../ActiveRecordingsTable.test.tsx.snap | 17 +- .../ArchivedRecordingsTable.test.tsx.snap | 11 +- 12 files changed, 879 insertions(+), 35 deletions(-) create mode 100644 src/app/Recordings/DateTimePicker.tsx create mode 100644 src/app/Recordings/LabelFilter.tsx create mode 100644 src/app/Recordings/NameFilter.tsx create mode 100644 src/app/Recordings/RecordingFilters.tsx diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 7650dd76f..418c5407a 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -51,25 +51,48 @@ 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 { RecordingsTable } from './RecordingsTable'; import { ReportFrame } from './ReportFrame'; import { DeleteWarningModal } from '../Modal/DeleteWarningModal'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; +export enum PanelContent { + LABELS, +} 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(); const [recordings, setRecordings] = React.useState([] as ActiveRecording[]); + const [filteredRecordings, setFilteredRecordings] = React.useState([] as ActiveRecording[]); const [headerChecked, setHeaderChecked] = React.useState(false); const [checkedIndices, setCheckedIndices] = React.useState([] as number[]); const [expandedRows, setExpandedRows] = React.useState([] as string[]); const [showDetailsPanel, setShowDetailsPanel] = React.useState(false); const [warningModalOpen, setWarningModalOpen] = React.useState(false); + const [panelContent, setPanelContent] = React.useState(PanelContent.LABELS); + const [filters, setFilters] = React.useState({ + Name: [], + Labels: [], + State: [], + StartedBeforeDate: [], + StartedAfterDate: [], + DurationSeconds: [], + } as RecordingFiltersCategories); const [isLoading, setIsLoading] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); const { url } = useRouteMatch(); @@ -95,8 +118,8 @@ export const ActiveRecordingsTable: React.FunctionComponent { setHeaderChecked(checked); - setCheckedIndices(checked ? Array.from(new Array(recordings.length), (x, i) => i) : []); - }, [setHeaderChecked, setCheckedIndices, recordings]); + setCheckedIndices(checked ? Array.from(new Array(filteredRecordings.length), (x, i) => i) : []); + }, [setHeaderChecked, setCheckedIndices, filteredRecordings]); const handleCreateRecording = React.useCallback(() => { routerHistory.push(`${url}/create`); @@ -104,6 +127,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { setShowDetailsPanel(true); + setPanelContent(PanelContent.LABELS); }, [setShowDetailsPanel]); const handleRecordings = React.useCallback((recordings) => { @@ -243,7 +267,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { const tasks: Observable[] = []; - recordings.forEach((r: ActiveRecording, idx) => { + filteredRecordings.forEach((r: ActiveRecording, idx) => { if (checkedIndices.includes(idx)) { handleRowCheck(false, idx); tasks.push( @@ -254,11 +278,11 @@ export const ActiveRecordingsTable: React.FunctionComponent {} /* do nothing */, window.console.error) ); - }, [recordings, checkedIndices, handleRowCheck, context.api, addSubscription]); + }, [filteredRecordings, checkedIndices, handleRowCheck, context.api, addSubscription]); const handleStopRecordings = React.useCallback(() => { const tasks: Observable[] = []; - recordings.forEach((r: ActiveRecording, idx) => { + filteredRecordings.forEach((r: ActiveRecording, idx) => { if (checkedIndices.includes(idx)) { handleRowCheck(false, idx); if (r.state === RecordingState.RUNNING || r.state === RecordingState.STARTING) { @@ -271,11 +295,11 @@ export const ActiveRecordingsTable: React.FunctionComponent {} /* do nothing */), window.console.error) ); - }, [recordings, checkedIndices, handleRowCheck, context.api, addSubscription]); + }, [filteredRecordings, checkedIndices, handleRowCheck, context.api, addSubscription]); const handleDeleteRecordings = React.useCallback(() => { const tasks: Observable<{}>[] = []; - recordings.forEach((r: ActiveRecording, idx) => { + filteredRecordings.forEach((r: ActiveRecording, idx) => { if (checkedIndices.includes(idx)) { context.reports.delete(r); tasks.push( @@ -286,7 +310,23 @@ export const ActiveRecordingsTable: React.FunctionComponent {} /* do nothing */), window.console.error) ); - }, [recordings, checkedIndices, context.reports, context.api, addSubscription]); + }, [filteredRecordings, checkedIndices, context.reports, context.api, addSubscription]); + + + const handleClearFilters = React.useCallback(() => { + setFilters({ + Name: [], + Labels: [], + State: [], + StartedBeforeDate: [], + StartedAfterDate: [], + DurationSeconds: [], + } as RecordingFiltersCategories); + }, [setFilters]); + + React.useEffect(() => { + setFilteredRecordings(filterRecordings(recordings, filters)); + }, [recordings, filters]); React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { @@ -445,10 +485,10 @@ export const ActiveRecordingsTable: React.FunctionComponent checkedIndices.includes(idx)); + const filtered = filteredRecordings.filter((r: ActiveRecording, idx: number) => checkedIndices.includes(idx)); const anyRunning = filtered.some((r: ActiveRecording) => r.state === RecordingState.RUNNING || r.state == RecordingState.STARTING); return !anyRunning; - }, [checkedIndices, recordings]); + }, [checkedIndices, filteredRecordings]); const buttons = React.useMemo(() => { const arr = [ @@ -489,8 +529,9 @@ export const ActiveRecordingsTable: React.FunctionComponent + + { buttons } { deleteActiveWarningModal } @@ -499,8 +540,8 @@ export const ActiveRecordingsTable: React.FunctionComponent { - return recordings.map((r, idx) => ) - }, [recordings, expandedRows, checkedIndices]); + return filteredRecordings.map((r, idx) => ) + }, [filteredRecordings, expandedRows, checkedIndices]); const LabelsPanel = React.useMemo(() => ( - {/* TODO change drawer panel content depending on which RecordingsToolbar button was clicked */} - + diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index cd3d89aa6..27b81b0b4 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -53,6 +53,8 @@ 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 { filterRecordings, RecordingFilters } from './RecordingFilters'; export interface ArchivedRecordingsTableProps { } @@ -60,11 +62,16 @@ export const ArchivedRecordingsTable: React.FunctionComponent { setHeaderChecked(checked); - setCheckedIndices(checked ? Array.from(new Array(recordings.length), (x, i) => i) : []); - }, [setHeaderChecked, setCheckedIndices, recordings]); + setCheckedIndices(checked ? Array.from(new Array(filteredRecordings.length), (x, i) => i) : []); + }, [setHeaderChecked, setCheckedIndices, filteredRecordings]); const handleRowCheck = React.useCallback((checked, index) => { if (checked) { @@ -126,6 +133,13 @@ export const ArchivedRecordingsTable: React.FunctionComponent { + setFilters({ + Name: [], + Labels: [], + } as RecordingFiltersCategories); + }, [setFilters]); + React.useEffect(() => { addSubscription( context.target.target().subscribe(refreshRecordingList) @@ -197,7 +211,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { const tasks: Observable[] = []; - recordings.forEach((r: ArchivedRecording, idx) => { + filteredRecordings.forEach((r: ArchivedRecording, idx) => { if (checkedIndices.includes(idx)) { context.reports.delete(r); tasks.push( @@ -208,13 +222,17 @@ 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]); }; + React.useEffect(() => { + setFilteredRecordings(filterRecordings(recordings, filters)); + }, [recordings, filters]); + React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { return; @@ -327,8 +345,9 @@ export const ArchivedRecordingsTable: React.FunctionComponent + + @@ -344,8 +363,8 @@ export const ArchivedRecordingsTable: React.FunctionComponent { - return recordings.map((r, idx) => ) - }, [recordings, expandedRows, checkedIndices]); + return filteredRecordings.map((r, idx) => ) + }, [filteredRecordings, expandedRows, checkedIndices]); const LabelsPanel = React.useMemo(() => ( - {/* TODO change drawer panel content depending on which RecordingsToolbar button was clicked */} {recordingRows} diff --git a/src/app/Recordings/DateTimePicker.tsx b/src/app/Recordings/DateTimePicker.tsx new file mode 100644 index 000000000..37f911a19 --- /dev/null +++ b/src/app/Recordings/DateTimePicker.tsx @@ -0,0 +1,109 @@ +/* + * 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 { + Button, + ButtonVariant, + DatePicker, + Flex, + FlexItem, + InputGroup, + isValidDate, + Text, + TimePicker, + yyyyMMddFormat, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; +import React from 'react'; + +export interface DateTimePickerProps { + onSubmit: (startDate) => void; +} + +export const DateTimePicker: React.FunctionComponent = (props) => { + const [start, setStart] = React.useState(new Date(0)); + const [searchDisabled, setSearchDisabled] = React.useState(true); + + const onStartDateChange = React.useCallback( + (inputDate, newStartDate) => { + if (isValidDate(start) && isValidDate(newStartDate) && inputDate === yyyyMMddFormat(newStartDate)) { + setStart(new Date(newStartDate)); + setSearchDisabled(false); + } else { + setSearchDisabled(true); + } + }, + [start, isValidDate, yyyyMMddFormat] + ); + + const onStartTimeChange = React.useCallback( + (unused, hour, minute) => { + let updated = new Date(start); + updated.setUTCHours(hour, minute); + setStart(updated); + }, + [start, setStart, isValidDate] + ); + + const handleSubmit = React.useCallback(() => { + props.onSubmit(`${start.toISOString()}`); + }, [start, props.onSubmit]); + + return ( + + + + + + + + + UTC + + + + + + ); +}; diff --git a/src/app/Recordings/LabelFilter.tsx b/src/app/Recordings/LabelFilter.tsx new file mode 100644 index 000000000..054568336 --- /dev/null +++ b/src/app/Recordings/LabelFilter.tsx @@ -0,0 +1,138 @@ +/* + * 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 { Label, Select, SelectOption, SelectVariant } from '@patternfly/react-core'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { NO_TARGET } from '@app/Shared/Services/Target.service'; +import { concatMap, filter, first, map } from 'rxjs'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { ArchivedRecording } from '@app/Shared/Services/Api.service'; + +export interface LabelFilterProps { + onSubmit: (inputName) => 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[]); + + const onSelect = React.useCallback( + (event, selection, isPlaceholder) => { + if (isPlaceholder) { + setIsOpen(false); + setSelected(''); + } else { + setSelected(selection); + props.onSubmit(selection); + } + }, + [props.onSubmit, setIsOpen, setSelected] + ); + + const refreshLabelList = React.useCallback(() => { + addSubscription( + context.target + .target() + .pipe( + filter((target) => target !== NO_TARGET), + concatMap(target => + context.api.graphql(` + query { + targetNodes(filter: { name: "${target.connectUrl}" }) { + recordings { + active { + name + metadata { + labels + } + } + archived { + name + metadata { + labels + } + } + } + } + }`) + ), + map(v => [...v.data.targetNodes[0].recordings.active as ArchivedRecording[], + ...v.data.targetNodes[0].recordings.archived as ArchivedRecording[]]), + first() + ) + .subscribe((recordings) => setLabels(old => { + let updated = new Set(old); + recordings.forEach((r) => { + if (!r || !r.metadata) return; + Object.entries(r.metadata.labels).map(([k, v]) => + updated.add(`${k}:${v}`) + ); + }); + return Array.from(updated); + })) + ); + }, [addSubscription, context, context.target, context.api]); + + React.useEffect(() => { + addSubscription(context.target.target().subscribe(refreshLabelList)); + }, [addSubscription, context, context.target, refreshLabelList]); + + return ( + + ); +}; diff --git a/src/app/Recordings/NameFilter.tsx b/src/app/Recordings/NameFilter.tsx new file mode 100644 index 000000000..db0b8d17b --- /dev/null +++ b/src/app/Recordings/NameFilter.tsx @@ -0,0 +1,121 @@ +/* + * 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 { useSubscriptions } from '@app/utils/useSubscriptions'; +import { NO_TARGET } from '@app/Shared/Services/Target.service'; +import { concatMap, filter, first, map } from 'rxjs'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { ArchivedRecording } from '@app/Shared/Services/Api.service'; + +export interface NameFilterProps { + onSubmit: (inputName) => 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[]); + + const onSelect = React.useCallback( + (event, selection, isPlaceholder) => { + if (isPlaceholder) { + setIsOpen(false); + setSelected(''); + } else { + setSelected(selection); + props.onSubmit(selection); + } + }, + [props.onSubmit, setIsOpen, setSelected] + ); + + const refreshRecordingList = React.useCallback(() => { + addSubscription( + context.target + .target() + .pipe( + filter((target) => target !== NO_TARGET), + concatMap(target => + context.api.graphql(` + query { + targetNodes(filter: { name: "${target.connectUrl}" }) { + recordings { + active { + name + } + archived { + name + } + } + } + }`) + ), + map(v => [...v.data.targetNodes[0].recordings.active as ArchivedRecording[], + ...v.data.targetNodes[0].recordings.archived as ArchivedRecording[]]), + first() + ) + .subscribe((recordings) => setNames(recordings.map((r) => r.name))) + ); + }, [addSubscription, context, context.target, context.api]); + + React.useEffect(() => { + addSubscription(context.target.target().subscribe(refreshRecordingList)); + }, [addSubscription, context, context.target, refreshRecordingList]); + + return ( + + ); +}; diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx new file mode 100644 index 000000000..dd232dde8 --- /dev/null +++ b/src/app/Recordings/RecordingFilters.tsx @@ -0,0 +1,349 @@ +/* + * 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 { RecordingState } from '@app/Shared/Services/Api.service'; +import { + Checkbox, + Dropdown, + DropdownItem, + DropdownPosition, + DropdownToggle, + Flex, + FlexItem, + InputGroup, + Select, + SelectOption, + SelectVariant, + TextInput, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import React, { Dispatch, SetStateAction } from 'react'; +import { RecordingFiltersCategories } from './ActiveRecordingsTable'; +import { DateTimePicker } from './DateTimePicker'; +import { LabelFilter } from './LabelFilter'; +import { NameFilter } from './NameFilter'; + +export interface RecordingFiltersProps { + filters: RecordingFiltersCategories; + setFilters: Dispatch>; +} + +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); + + const onCategoryToggle = React.useCallback(() => { + setIsCategoryDropdownOpen((opened) => !opened); + }, [setIsCategoryDropdownOpen]); + + const onCategorySelect = React.useCallback( + (curr) => { + setCurrentCategory(curr); + }, + [setCurrentCategory] + ); + + const onFilterToggle = React.useCallback(() => { + setIsFilterDropdownOpen((opened) => !opened); + }, [setIsFilterDropdownOpen]); + + 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: [], + }; + }); + } + }, + [props.setFilters] + ); + + 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] + ); + + 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] + ); + + 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 dates = new Set(old.StartedAfterDate); + dates.add(searchDate); + return { ...old, StartedAfterDate: Array.from(dates) }; + }); + }, [props.setFilters]); + + 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) }; + }); + }, + [duration, props.setFilters] + ); + + const onRecordingStateSelect = React.useCallback( + (e, 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] + ); + + 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'), + }; + }); + }, + [setContinuous, props.setFilters] + ); + + const categoryDropdown = React.useMemo(() => { + return ( + + + {currentCategory} + + } + isOpen={isCategoryDropdownOpen} + dropdownItems={[ + Object.keys(props.filters).map((cat) => ( + onCategorySelect(cat)}> + {cat} + + )), + ]} + > + + ); + }, [Object.keys(props.filters), isCategoryDropdownOpen, onCategoryToggle, onCategorySelect]); + + const filterDropdownItems = React.useMemo( + () => [ + + + , + + + , + , + + + , + + + , + + + + setDuration(Number(e))} + min="0" + onKeyDown={onDurationInput} + /> + + + onContinuousDurationSelect(checked)} + /> + + + , + ], + [Object.keys(props.filters)] + ); + + return ( + } breakpoint="xl"> + + {categoryDropdown} + {Object.keys(props.filters).map((filterKey, i) => ( + + {filterDropdownItems[i]} + + ))} + + + ); +}; + +export const filterRecordings = (recordings, filters) => { + if (!recordings || !recordings.length) { + return recordings; + } + + let filtered = recordings; + + if (!!filters.Name.length) { + filtered = filtered.filter((r) => filters.Name.includes(r.name)); + } + if (!!filters.State && !!filters.State.length) { + filtered = filtered.filter((r) => !!filters.State && filters.State.includes(r.state)); + } + if (!!filters.DurationSeconds && !!filters.DurationSeconds.length) { + filtered = filtered.filter( + (r) => { + if (!filters.DurationSeconds) return true; + return filters.DurationSeconds.includes(`${r.duration / 1000} s`) || + (filters.DurationSeconds.includes('continuous') && r.continuous); + }); + } + if (!!filters.StartedBeforeDate && !!filters.StartedBeforeDate.length) { + filtered = filtered.filter((rec) => { + if (!filters.StartedBeforeDate) return true; + + return filters.StartedBeforeDate.filter((startedBefore) => { + const beforeDate = new Date(startedBefore); + return rec.startTime < beforeDate.getTime(); + }).length; + }); + } + if (!!filters.StartedAfterDate && !!filters.StartedAfterDate.length) { + filtered = filtered.filter((rec) => { + if (!filters.StartedAfterDate) return true; + return filters.StartedAfterDate.filter((startedAfter) => { + const afterDate = new Date(startedAfter); + + return rec.startTime > afterDate.getTime(); + }).length; + }); + } + if (!!filters.Labels.length) { + filtered = filtered.filter((r) => + Object.entries(r.metadata.labels) + .filter(([k,v]) => filters.Labels.includes(`${k}:${v}`)).length + ); + } + + return filtered; +} diff --git a/src/app/Recordings/RecordingsTable.tsx b/src/app/Recordings/RecordingsTable.tsx index c54fa7123..bd2f02868 100644 --- a/src/app/Recordings/RecordingsTable.tsx +++ b/src/app/Recordings/RecordingsTable.tsx @@ -36,7 +36,7 @@ * SOFTWARE. */ import * as React from 'react'; -import { Title, EmptyState, EmptyStateIcon } from '@patternfly/react-core'; +import { Title, EmptyState, EmptyStateIcon, EmptyStateBody, Button, EmptyStateSecondaryActions } from '@patternfly/react-core'; import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; import { TableComposable, Thead, Tr, Th } from '@patternfly/react-table'; import { LoadingView } from '@app/LoadingView/LoadingView'; @@ -47,10 +47,12 @@ export interface RecordingsTableProps { tableColumns: string[]; tableTitle: string; isEmpty: boolean; + isEmptyFilterResult?: boolean; isHeaderChecked: boolean; isLoading: boolean; errorMessage: string; onHeaderCheck: (event, checked: boolean) => void; + clearFilters?: (filterType) => void; } export const RecordingsTable: React.FunctionComponent = (props) => { @@ -68,6 +70,24 @@ export const RecordingsTable: React.FunctionComponent = (p ); + } else if (props.isEmptyFilterResult) { + view = (<> + + + + No {props.tableTitle} found + + + No results match this filter criteria. + Remove all filters or clear all filters to show results. + + + + + + ); } else { view = (<> diff --git a/src/app/app.css b/src/app/app.css index fe2041bdc..f1c222df3 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -64,3 +64,16 @@ html, body, #root { .recordings-table-drawer-content { min-height: 23rem; } + +.time-picker { + width: 10rem; +} + +.pf-c-chip-group { + max-width: 100ch; +} + +.pf-c-chip { + --pf-c-chip__text--MaxWidth: 100ch; +} + diff --git a/src/test/Recordings/ActiveRecordingsTable.test.tsx b/src/test/Recordings/ActiveRecordingsTable.test.tsx index 69af74727..94f42d4fd 100644 --- a/src/test/Recordings/ActiveRecordingsTable.test.tsx +++ b/src/test/Recordings/ActiveRecordingsTable.test.tsx @@ -87,6 +87,17 @@ jest.mock('react-router-dom', () => ({ }), })); +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'; diff --git a/src/test/Recordings/ArchivedRecordingsTable.test.tsx b/src/test/Recordings/ArchivedRecordingsTable.test.tsx index 21da5c75d..6ebf6373f 100644 --- a/src/test/Recordings/ArchivedRecordingsTable.test.tsx +++ b/src/test/Recordings/ArchivedRecordingsTable.test.tsx @@ -79,6 +79,17 @@ const mockLabelsNotification = { } as NotificationMessage; const mockDeleteNotification = { message: { target: mockConnectUrl, recording: mockRecording } } as NotificationMessage; +jest.mock('@app/Recordings/RecordingFilters', () => { + return { + ...jest.requireActual('@app/Recordings/RecordingFilters'), + RecordingFilters: jest.fn(() => { + return
+ RecordingFilters +
+ }) + }; +}); + import { ArchivedRecordingsTable } from '@app/Recordings/ArchivedRecordingsTable'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import { DeleteActiveRecordings, DeleteArchivedRecordings, DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; diff --git a/src/test/Recordings/__snapshots__/ActiveRecordingsTable.test.tsx.snap b/src/test/Recordings/__snapshots__/ActiveRecordingsTable.test.tsx.snap index 64b282a6b..4d98a0cc3 100644 --- a/src/test/Recordings/__snapshots__/ActiveRecordingsTable.test.tsx.snap +++ b/src/test/Recordings/__snapshots__/ActiveRecordingsTable.test.tsx.snap @@ -15,7 +15,7 @@ exports[` renders correctly 1`] = ` >
renders correctly 1`] = `
+
+ RecordingFilters +
@@ -33,7 +36,7 @@ exports[` renders correctly 1`] = ` aria-disabled={false} aria-label={null} className="pf-c-button pf-m-primary" - data-ouia-component-id="OUIA-Generated-Button-primary-2" + data-ouia-component-id="OUIA-Generated-Button-primary-3" data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={false} @@ -51,7 +54,7 @@ exports[` renders correctly 1`] = ` aria-disabled={true} aria-label={null} className="pf-c-button pf-m-secondary pf-m-disabled" - data-ouia-component-id="OUIA-Generated-Button-secondary-3" + data-ouia-component-id="OUIA-Generated-Button-secondary-5" data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={true} @@ -70,7 +73,7 @@ exports[` renders correctly 1`] = ` aria-disabled={true} aria-label={null} className="pf-c-button pf-m-secondary pf-m-disabled" - data-ouia-component-id="OUIA-Generated-Button-secondary-4" + data-ouia-component-id="OUIA-Generated-Button-secondary-6" data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={true} @@ -89,7 +92,7 @@ exports[` renders correctly 1`] = ` aria-disabled={true} aria-label={null} className="pf-c-button pf-m-tertiary pf-m-disabled" - data-ouia-component-id="OUIA-Generated-Button-tertiary-2" + data-ouia-component-id="OUIA-Generated-Button-tertiary-3" data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={true} @@ -108,7 +111,7 @@ exports[` renders correctly 1`] = ` aria-disabled={true} aria-label={null} className="pf-c-button pf-m-danger pf-m-disabled" - data-ouia-component-id="OUIA-Generated-Button-danger-2" + data-ouia-component-id="OUIA-Generated-Button-danger-3" data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={true} @@ -123,7 +126,7 @@ exports[` renders correctly 1`] = `
renders correctly 1`] = ` >
renders correctly 1`] = `
+
+ RecordingFilters +
@@ -36,7 +39,7 @@ exports[` renders correctly 1`] = ` aria-disabled={true} aria-label={null} className="pf-c-button pf-m-secondary pf-m-disabled" - data-ouia-component-id="OUIA-Generated-Button-secondary-2" + data-ouia-component-id="OUIA-Generated-Button-secondary-3" data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={true} @@ -55,7 +58,7 @@ exports[` renders correctly 1`] = ` aria-disabled={true} aria-label={null} className="pf-c-button pf-m-danger pf-m-disabled" - data-ouia-component-id="OUIA-Generated-Button-danger-2" + data-ouia-component-id="OUIA-Generated-Button-danger-3" data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={true} @@ -71,7 +74,7 @@ exports[` renders correctly 1`] = `