Skip to content

Commit

Permalink
feat(recordings): more tools for filtering by labels (#503)
Browse files Browse the repository at this point in the history
* fix(tests,history): specify initEntries for createMemoryHistory

* fix(test, recordings): add router for all rendered components

* fix(test, activeRecording): add cleanup after each tests

* fix(filters): filter dropdown should close after selecting

* chore(filters): move state filter to its own source file

* fix(filters): current filter category should remain

* fix(filters): users should able to bulk delete filters in the same category

* !tmp(tests): add mocks until new filter features are implemented

* chore(filters): move durationFilter to a separate source file

* chore(filters): move filters to subdirectory of Recordings

* chore(filters): renaming variables

* chore(filters): move filter category interfaces to RecordingFilters file

* fix(filters): filtered labels should be highlighted

* fix(filters): recording labels are now clickable

* fix(test-snapshots): update test snapshots

* fix(filters): add missing deps in useCallBacks

* fix(filters): better naming for props callback

* fix(filters): use a common func to get label display format

* fix(filters): better handling of label filter search

* feat(storage): add utility func to store filter states to local storage

* feat(storage): set up Redux for state management

* fix(storage): only save recording filters to local storage

* fix(redux): redux should store unfiltered recordings

* chore(storage): move local storage utilities to util directory

* tmp: save selected indices of recordings to redux store

* fix(redux): update redux actions and reducers

* feat(filters): recording filters now persist across views

* fix(recording-tables): add missing deps for react callbacks and memos

* fix(filters): fix clearAllFilters button

* feat(labels): clickable labels should be highlighted on hover

* fix(applayout): add unique keys for each child component in list

* feat(filters): filter states should be saved to local storage

* fix(filters): state filter now allows toggling

* fix(filters): fix datetime picker component layout

* chore(filters): rename disPatch to dispatch

* fix(filters): add explain comments for datetime picker appendTo props

* test(filters): add test for NameFilter

* fix(filter): header check should update when filter changes

* fix(filters): labels in filter should be sorted

* fix(filter): optimize callbacks for NameFilter

* fix(active-recording-table): header and checked indices now update properly

* fix(archived-recording-table): header and checked indices now update properly

* fix(bulk-edit): bulk-edit must choose correctly selected rows

* fix(filters): name and label filters should excluded the selected options

* fix(filters): update callback deps

* fix(fitlers): remove unused states in filter

* fix(jest): add jest-dom as a setup file for nested test files

* test(filters): update tests for NameFilter

* test(filters): add unit test for LabelFilter

* fix(filters): remove log calls

* test(filter): add test for DurationFilter

* test(filter): add test for RecordingStateFilter

* test(filters): fix RecordingStateFilter tests

* fix(datetimepicker): set document.body as default element to bind popover

* test(filters): check if recording state filter is closed on toggle

* fix(datetimepicker): datetime is now correct without time set and time change should be reflected in datetime

* test(filters): add tests for DateTimePicker

* fix(datetimepicker): move func to find menu mount point outside component

* test(datepicker): fix test imports

* chore(filters): move ClickableLabel to separate source file

* test(clickable-label): add unit tests for clickable label

* tests(filters): add tests for LabelCell

* fix(filters): calculate hash for archive recording indexes

* fix(bulk-edit): remove unnecessary notification sub

* fix(bulk-edit): use props to check if a recording list is active/archived

* tests(bulk-edit): add unit tests for labe bulk-edit

* fix(redux): reword comment and add type hints

* feat(redux): add type for root state and dispatch

* fix(redux): export wrapper setupStore

* feat(redux): allow preloading state into store and define redux type hints

* feat(redux-test): add wrapper for redux Provider in tests

* test(filters): add unit test for recording fitlers

* test(recordings): fix tests for active recording table

* test(recordings): fix archived recording table tests

* fix(local-storage): update comments

* chore(filters): add missing memo deps

* test(filters): add check if approriate filter input is shown

* chore(filters): refactor toolbar from recording table

* fix(filters): label and name filter should close menu on selection

* fix(toolbars): delete warning should be open on clicked if enabled

* fix(recordings): limit calls to context.settings with React memo

* test(recordings): fix tests

* fix(recordings): fix drawer id

* test(filter): fix datepicker filter

* test(active-recording): update snapshots

* test(datepicker): clean up date mocks

* chore(filters): rename, clean up comments and callbacks

* fix(labelcell): group prop fields

* chore(filters): rename recording categories to singulars

* chore(filters): move hashCode method to utils module

* fix(filters): add missing deps

* fix(delete-modal): move delete-modal flag inside toolbars

* fix(filters): mount menu to document.body

* fix(datepicker): remove unused deps

* fix(filters): fix filter name should in singulars

* test(filters): update tests

* fix(filters): do not render unncessary filters in Archived view

* fix(filters): filters display should be fixed for each type of recording
  • Loading branch information
Thuan Vo authored Sep 20, 2022
1 parent 13d9d45 commit b2695af
Show file tree
Hide file tree
Showing 49 changed files with 4,591 additions and 953 deletions.
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['<rootDir>/test-setup.js'],
setupFilesAfterEnv: ['<rootDir>/test-setup.js', "@testing-library/jest-dom"],

// The test environment that will be used for testing.
testEnvironment: "jsdom",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion src/app/AppLayout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ const AppLayout: React.FunctionComponent<IAppLayout> = ({children}) => {
<NavList id="nav-list-simple">
{navGroups.map((title) => {
return (
<NavGroup title={title}>
<NavGroup title={title} key={title}>
{routes.filter(route => route.navGroup === title)
.map((route, idx) => {
return (
Expand Down Expand Up @@ -295,6 +295,7 @@ const AppLayout: React.FunctionComponent<IAppLayout> = ({children}) => {
.map(( { key, title, message, variant } ) => (
<Alert
variant={variant}
key={title}
title={title}
actionClose={<AlertActionCloseButton onClose={() => handleMarkNotificationRead(key)} />}
timeout={true}
Expand Down
2 changes: 1 addition & 1 deletion src/app/Modal/DeleteWarningUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,4 @@ export const DeleteWarningKinds : DeleteWarning[] = [
export const getFromWarningMap = (warning: DeleteWarningType): DeleteWarning | undefined => {
const wt = DeleteWarningKinds.find(t => t.id === warning);
return wt;
}
}
40 changes: 18 additions & 22 deletions src/app/RecordingMetadata/BulkEditLabels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { hashCode } from '@app/utils/utils';

export interface BulkEditLabelsProps {
isTargetRecording: boolean;
Expand All @@ -62,11 +63,20 @@ export const BulkEditLabels: React.FunctionComponent<BulkEditLabelsProps> = (pro
const [valid, setValid] = React.useState(ValidatedOptions.default);
const addSubscription = useSubscriptions();

const getIdxFromRecording = React.useCallback((r: ArchivedRecording): number => {
if (props.isTargetRecording) {
return (r as ActiveRecording).id;
} else {
return hashCode(r.name);
}
}, [hashCode, props.isTargetRecording]);

const handleUpdateLabels = React.useCallback(() => {
const tasks: Observable<any>[] = [];
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) => {
Expand Down Expand Up @@ -106,7 +116,9 @@ export const BulkEditLabels: React.FunctionComponent<BulkEditLabelsProps> = (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));
}
Expand Down Expand Up @@ -159,25 +171,8 @@ export const BulkEditLabels: React.FunctionComponent<BulkEditLabelsProps> = (pro
addSubscription(context.target.target().subscribe(refreshRecordingList));
}, [addSubscription, context, context.target, refreshRecordingList]);

React.useEffect(() => {
addSubscription(
combineLatest([
context.target.target(),
merge(
context.notificationChannel.messages(NotificationCategory.ActiveRecordingDeleted),
context.notificationChannel.messages(NotificationCategory.SnapshotDeleted)
),
]).subscribe((parts) => {
const currentTarget = parts[0];
const event = parts[1];
if (currentTarget.connectUrl != event.message.target) {
return;
}
setRecordings((old) => old.filter((r) => r.name != event.message.recording.name));
})
);
}, [addSubscription, context, context.notificationChannel, setRecordings]);

// Depends only on RecordingMetadataUpdated notifications
// since updates on list of recordings will mount a completely new BulkEditLabels.
React.useEffect(() => {
addSubscription(
combineLatest([
Expand Down Expand Up @@ -236,7 +231,7 @@ export const BulkEditLabels: React.FunctionComponent<BulkEditLabelsProps> = (pro
</Split>
</StackItem>
<StackItem>
<LabelCell labels={savedCommonLabels} />
<LabelCell target='' labels={savedCommonLabels} />
</StackItem>
<StackItem>
{editing ? (
Expand All @@ -263,6 +258,7 @@ export const BulkEditLabels: React.FunctionComponent<BulkEditLabelsProps> = (pro
) : (
<Button
key="edit labels"
aria-label='Edit Labels'
variant="secondary"
onClick={handleEditLabels}
isDisabled={!props.checkedIndices.length}
Expand Down
88 changes: 88 additions & 0 deletions src/app/RecordingMetadata/ClickableLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* 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<ClickableLabelCellProps> = (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 <>
<Label
aria-label={`${props.label.key}: ${props.label.value}`}
style={style}
onMouseEnter={handleHoveredOrFocused}
onMouseLeave={handleNonHoveredOrFocused}
onFocus={handleHoveredOrFocused}
onClick={handleLabelClicked}
key={props.label.key}
color={labelColor}
>
{`${props.label.key}: ${props.label.value}`}
</Label>
</>;
}
47 changes: 43 additions & 4 deletions src/app/RecordingMetadata/LabelCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,60 @@
* SOFTWARE.
*/

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 {
target: string;
labels: RecordingLabel[];
clickableOptions?: { // If undefined, labels are not clickable (i.e. display only) and only displayed in grey.
labelFilters: string[];
updateFilters: (target: string, updateFilterOptions: UpdateFilterOptions) => void
}
}

export const LabelCell: React.FunctionComponent<LabelCellProps> = (props) => {
// TODO make labels clickable to select multiple recordings with the same label
const isLabelSelected = React.useCallback((label: RecordingLabel) => {
if (props.clickableOptions) {
const labelFilterSet = new Set(props.clickableOptions.labelFilters);
return labelFilterSet.has(getLabelDisplay(label))
}
return false;
}, [getLabelDisplay, props.clickableOptions]);

const getLabelColor = React.useCallback((label: RecordingLabel) => isLabelSelected(label)? "blue": "grey", [isLabelSelected]);
const onLabelSelectToggle = React.useCallback(
(clickedLabel: RecordingLabel) => {
if (props.clickableOptions) {
props.clickableOptions.updateFilters(props.target, {filterKey: "Label", filterValue: getLabelDisplay(clickedLabel), deleted: isLabelSelected(clickedLabel)})
}
},
[props.clickableOptions, props.target, getLabelDisplay]);

return (
<>
{!!props.labels && props.labels.length ? (
props.labels.map((l) => <Label key={l.key} color="grey">{`${l.key}: ${l.value}`}</Label>)
) : (
{!!props.labels && props.labels.length? (
props.labels.map((label) =>
props.clickableOptions?
<ClickableLabel
key={label.key}
label={label}
isSelected={isLabelSelected(label)}
onLabelClick={onLabelSelectToggle}
/> :
<Label
aria-label={`${label.key}: ${label.value}`}
key={label.key}
color={getLabelColor(label)}
>
{`${label.key}: ${label.value}`}
</Label>

)) : (
<Text>-</Text>
)}
</>
Expand Down
4 changes: 2 additions & 2 deletions src/app/RecordingMetadata/RecordingLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion src/app/RecordingMetadata/RecordingLabelFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export const RecordingLabelFields: React.FunctionComponent<RecordingLabelFieldsP

return (
<>
<Button onClick={handleAddLabelButtonClick} variant="link" icon={<PlusCircleIcon />}>
<Button aria-label='Add Label' onClick={handleAddLabelButtonClick} variant="link" icon={<PlusCircleIcon />}>
Add Label
</Button>
{!!props.labels && props.labels.map((label, idx) => (
Expand Down
Loading

0 comments on commit b2695af

Please sign in to comment.